**16**

# Hans Petter Langtangen · Svein Linge **Finite Diff erence Computing with PDEs**

A Modern Software Approach **Editorial Board**

**T. J. Barth M. Griebel D. E. Keyes R. M. Nieminen D. Roose T. Schlick**

# **Texts in Computational Science and Engineering 16**

Editors

Timothy J. Barth Michael Griebel David E. Keyes Risto M. Nieminen Dirk Roose Tamar Schlick

More information about this series at http://www.springer.com/series/5151

Hans Petter Langtangen -Svein Linge

# Finite Difference Computing with PDEs

A Modern Software Approach

Hans Petter Langtangen Lysaker, Norway

Svein Linge Process, Energy & Environmental Technology University College of Southeast Norway Porsgrunn, Norway

ISSN 1611-0994 Texts in Computational Science and Engineering ISBN 978-3-319-55455-6 ISBN 978-3-319-55456-3 (eBook) DOI 10.1007/978-3-319-55456-3

Library of Congress Control Number: 2017942747

Mathematic Subject Classification (2010): 34, 35, 65, 68

© The Editor(s) (if applicable) and The Author(s) 2017 This book is an open access publication **Open Access** This book is distributed under the terms of the Creative Commons Attribution 4.0 International License (http://creativecommons.org/licenses/by/4.0/), which permits use, duplication, adaptation, distribution and reproduction in any medium or format, as long as you give appropriate credit to the original author(s) and the source, provide a link to the Creative Commons license and indicate if changes were made.

The images or other third party material in this book are included in the work's Creative Commons license, unless indicated otherwise in the credit line; if such material is not included in the work's Creative Commons license and the respective action is not permitted by statutory regulation, users will need to obtain permission from the license holder to duplicate, adapt or reproduce the material.

The use of general descriptive names, registered names, trademarks, service marks, etc. in this publication does not imply, even in the absence of a specific statement, that such names are exempt from the relevant protective laws and regulations and therefore free for general use.

The publisher, the authors and the editors are safe to assume that the advice and information in this book are believed to be true and accurate at the date of publication. Neither the publisher nor the authors or the editors give a warranty, express or implied, with respect to the material contained herein or for any errors or omissions that may have been made. The publisher remains neutral with regard to jurisdictional claims in published maps and institutional affiliations.

Printed on acid-free paper

This Springer imprint is published by Springer Nature The registered company is Springer International Publishing AG The registered company address is: Gewerbestrasse 11, 6330 Cham, Switzerland

# **Preface**

There are so many excellent books on finite difference methods for ordinary and partial differential equations that writing yet another one requires a different view on the topic. The present book is not so concerned with the traditional academic presentation of the topic, but is focused at teaching the practitioner how to obtain reliable computations involving finite difference methods. This focus is based on a set of learning outcomes:


Compared to other textbooks, the present one has a particularly strong emphasis on computer implementation and verification. It also has a strong emphasis on an intuitive understanding of constructing finite difference methods. To learn about the potential non-physical artifacts of various methods, we study exact solutions of finite difference schemes as these give deeper insight into the physical behavior of the numerical methods than the traditional (and more general) asymptotic error analysis. However, asymptotic results regarding convergence rates, typically truncation errors, are crucial for testing implementations, so an extensive appendix is devoted to the computation of truncation errors.

**Why finite differences?** One may ask why we do finite differences when finite element and finite volume methods have been developed to greater generality and sophistication than finite differences and can cover more problems. The finite element and finite volume methods are also the industry standard nowadays. Why not just those methods? The reason for finite differences is the method's simplicity, both from a mathematical and coding perspective. Especially in academia, where simple model problems are used a lot for teaching and in research (e.g., for verification of advanced implementations), there is a constant need to solve the model problems from scratch with easy-to-verify computer codes. Here, finite differences are ideal. A simple 1D heat equation can of course be solved by a finite element package, but a 20-line code with a difference scheme is just right to the point and provides an understanding of all details involved in the model and the solution method. Everybody nowadays has a laptop and the natural method to attack a 1D heat equation is a simple Python or Matlab program with a difference scheme. The conclusion goes for other fundamental PDEs like the wave equation and Poisson equation as long as the geometry of the domain is a hypercube. The present book contains all the practical information needed to use the finite difference tool in a safe way.

Various pedagogical elements are utilized to reach the learning outcomes, and these are commented upon next.

**Simplify, understand, generalize** The book's overall pedagogical philosophy is the three-step process of first *simplifying* the problem to something we can *understand* in detail, and when that understanding is in place, we can *generalize* and hopefully address real-world applications with a sound scientific problem-solving approach. For example, in the chapter on a particular family of equations we first simplify the problem in question to a 1D, constant-coefficient equation with simple boundary conditions. We learn how to construct a finite difference method, how to implement it, and how to understand the behavior of the numerical solution. Then we can generalize to higher dimensions, variable coefficients, a source term, and more complicated boundary conditions. The solution of a compound problem is in this way an assembly of elements that are well understood in simpler settings.

**Constructive mathematics** This text favors a constructive approach to mathematics. Instead of a set of definitions followed by popping up a method, we emphasize how to think about the construction of a method. The aim is to obtain a good intuitive understanding of the mathematical methods.

The text is written in an easy-to-read style much inspired by the following quote.

*Some people think that stiff challenges are the best device to induce learning, but I am not one of them. The natural way to learn something is by spending vast amounts of easy, enjoyable time at it. This goes whether you want to speak German, sight-read at the piano, type, or do mathematics. Give me the German storybook for fifth graders that I feel like reading in bed, not Goethe and a dictionary. The latter will bring rapid progress at first, then exhaustion and failure to resolve.*

*The main thing to be said for stiff challenges is that inevitably we will encounter them, so we had better learn to face them boldly. Putting them in the curriculum can help teach us to do so. But for teaching the skill or subject matter itself, they are overrated.* [18, p. 86] Lloyd N. Trefethen, Applied Mathematician, 1955-.

This book assumes some basic knowledge of finite difference approximations, differential equations, and scientific Python or MATLAB programming, as often met in an introductory numerical methods course. Readers without this background may start with the light companion book "Finite Difference Computing with Exponential Decay Models" [9]. That book will in particular be a useful resource for the programming parts of the present book. Since the present book deals with partial differential equations, the reader is assumed to master multi-variable calculus and linear algebra.

Fundamental ideas and their associated scientific details are first introduced in the simplest possible differential equation setting, often an ordinary differential equation, but in a way that easily allows reuse in more complex settings with partial differential equations. With this approach, new concepts are introduced with a minimum of mathematical details. The text should therefore have a potential for use early in undergraduate student programs.

**All nuts and bolts** Many have experienced that "vast amounts of easy, enjoyable time", as stated in the quote above, arises when mathematics is implemented on a computer. The implementation process triggers understanding, creativity, and curiosity, but many students find the transition from a mathematical algorithm to a working code difficult and spend a lot of time on "programming issues".

Most books on numerical methods concentrate on the mathematics of the subject while details on going from the mathematics to a computer implementation are less in focus. A major purpose of this text is therefore to help the practitioner by providing *all nuts and bolts* necessary for safely going from the mathematics to a well-designed and well-tested computer code. A significant portion of the text is consequently devoted to programming details.

**Python as programming language** While MATLAB enjoys widespread popularity in books on numerical methods, we have chosen to use the Python programming language. Python is very similar to MATLAB, but contains a lot of modern software engineering tools that have become standard in the software industry and that should be adopted also for numerical computing projects. Python is at present also experiencing an exponential growth in popularity within the scientific computing community. One of the book's goals is to present an up-to-date Python eco system for implementing finite difference methods.

**Program verification** Program testing, called *verification*, is a key topic of the book. Good verification techniques are indispensable when debugging computer code, but also fundamental for achieving reliable simulations. Two verification techniques saturate the book: exact solution of discrete equations (where the approximation error vanishes) and empirical estimation of convergence rates in problems with exact (analytical or manufactured) solutions of the differential equation(s).

**Vectorized code** Finite difference methods lead to code with loops over large arrays. Such code in plain Python is known to run slowly. We demonstrate, especially in Appendix C, how to port loops to fast, compiled code in C or Fortran. However, an alternative is to vectorize the code to get rid of explicit Python loops, and this technique is met throughout the book. Vectorization becomes closely connected to the underlying array library, here numpy, and is often thought of as a difficult subject by students. Through numerous examples in different contexts, we hope that the present book provides a substantial contribution to explaining how algorithms can be vectorized. Not only will this speed up serial code, but with a library that can produce parallel code from numpy commands (such as Numba1), vectorized code can be automatically turned into parallel code and utilize multi-core processors and GPUs. Also when creating tailored parallel code for today's supercomputers, vectorization is useful as it emphasizes splitting up an algorithm into plain and simple

<sup>1</sup> http://numba.pydata.org

array operations, where each operation is trivial to parallelize efficiently, rather than trying to develop a "smart" overall parallelization strategy.

**Analysis via exact solutions of discrete equations** Traditional asymptotic analysis of errors is important for verification of code using convergence rates, but gives a limited understanding of how and why a correctly implemented numerical method may give non-physical results. By developing exact solutions, usually based on Fourier methods, of the discrete equations, one can obtain a physical understanding of the behavior of a numerical method. This approach is favored for analysis of methods in this book.

**Code-inspired mathematical notation** Our primary aim is to have a clean and easy-to-read computer code, and we want a close one-to-one relationship between the computer code and mathematical description of the algorithm. This principle calls for a mathematical notation that is governed by the natural notation in the computer code. The unknown is mostly called u, but the meaning of the symbol u in the mathematical description changes as we go from the exact solution fulfilling the differential equation to the symbol u that is naturally used for the associated data structure in the code.

**Limited scope** The aim of this book is not to give an overview of a lot of methods for a wide range of mathematical models. Such information can be found in numerous existing, more advanced books. The aim is rather to introduce basic concepts and a thorough understanding of how to think about computing with finite difference methods. We therefore go in depth with only the most fundamental methods and equations. However, we have a multi-disciplinary scope and address the interplay of mathematics, numerics, computer science, and physics.

**Focus on wave phenomena** Most books on finite difference methods, or books on theory with computer examples, have their emphasis on diffusion phenomena. Half of this book (Chap. 1, 2, and Appendix C) is devoted to wave phenomena. Extended material on this topic is not so easy find in the literature, so the book should be a valuable contribution in this respect. Wave phenomena is also a good topic in general for choosing the finite difference method over other discretization methods since one quickly needs fine resolution over the entire mesh and uniform meshes are most natural.

Instead of introducing the finite difference method for diffusion problems, where one soon ends up with matrix systems, we do the introduction in a wave phenomena setting where explicit schemes are most relevant. This slows down the learning curve since we can introduce a lot of theory for differences and for software aspects in a context with simple, explicit stencils for updating the solution.

**Independent chapters** Most book authors are careful with avoiding repetitions of material. The chapters in this book, however, contain some overlap, because we want the chapters to appear meaningful on their own. Modern publishing technology makes it easy to take selected chapters from different books to make a new book tailored to a specific course. The more a chapter builds on details in other chapters, the more difficult it is to reuse chapters in new contexts. Also, most readers find it convenient that important information is explicitly stated, even if it was already met in another chapter.

**Supplementary materials** All program and data files referred to in this book are available from the book's primary web site: URL: http://github.com/hplgit/fdmbook/.

**Acknowledgments** Professor Kent-Andre Mardal at the University of Oslo has kindly contributed to enlightening discussions on several issues. Many students have provided lots of useful feedback on the exposition and found many errors in the text. Special efforts in this regard were made by Imran Ali, Shirin Fallahi, Anders Hafreager, Daniel Alexander Mo Søreide Houshmand, Kristian Gregorius Hustad, Mathilde Nygaard Kamperud, and Fatemeh Miri. The collaboration with the Springer team, with Dr. Martin Peters, Thanh-Ha Le Thi, and their production staff has always been a great pleasure and a very efficient process.

Finally, want really appreciate the strong push from the COE of Simula Research Laboratory, Aslak Tveito, for publishing and financing books in open access format, including this one. We are grateful for the laboratory's financial contribution as well as to the financial contribution from the Department of Process, Energy and Environmental Technology at the University College of Southeast Norway.

Oslo, July 2016 Hans Petter Langtangen, Svein Linge

# **Contents**










## **List of Exercises, Problems and Projects**




# **1 Vibration ODEs**

Vibration problems lead to differential equations with solutions that oscillate in time, typically in a damped or undamped sinusoidal fashion. Such solutions put certain demands on the numerical methods compared to other phenomena whose solutions are monotone or very smooth. Both the frequency and amplitude of the oscillations need to be accurately handled by the numerical schemes. The forthcoming text presents a range of different methods, from classical ones (Runge-Kutta and midpoint/Crank-Nicolson methods), to more modern and popular symplectic (geometric) integration schemes (Leapfrog, Euler-Cromer, and Störmer-Verlet methods), but with a clear emphasis on the latter. Vibration problems occur throughout mechanics and physics, but the methods discussed in this text are also fundamental for constructing successful algorithms for partial differential equations of wave nature in multiple spatial dimensions.

#### **1.1 Finite Difference Discretization**

Many of the numerical challenges faced when computing oscillatory solutions to ODEs and PDEs can be captured by the very simple ODE <sup>u</sup><sup>00</sup> C <sup>u</sup> D <sup>0</sup>. This ODE is thus chosen as our starting point for method development, implementation, and analysis.

#### **1.1.1 A Basic Model for Vibrations**

The simplest model of a vibrating mechanical system has the following form:

$$
\mu'' + \omega^2 \mu = 0, \quad \mu(0) = I, \; \mu'(0) = 0, \; t \in (0, T]. \tag{1.1}
$$

Here, ! and I are given constants. Section 1.12.1 derives (1.1) from physical principles and explains what the constants mean.

The exact solution of (1.1) is

$$u(t) = I\cos(\alpha t) \,. \tag{1.2}$$

© The Author(s) 2017 1

H.P. Langtangen, S. Linge, *Finite Difference Computing with PDEs*,

Texts in Computational Science and Engineering 16, DOI 10.1007/978-3-319-55456-3\_1

That is, u oscillates with constant amplitude I and angular frequency !. The corresponding period of oscillations (i.e., the time between two neighboring peaks in the cosine function) is <sup>P</sup> D 2=!. The number of periods per second is <sup>f</sup> D !=.2/ and measured in the unit Hz. Both f and ! are referred to as frequency, but ! is more precisely named *angular frequency*, measured in rad/s.

In vibrating mechanical systems modeled by (1.1), u.t/ very often represents a position or a displacement of a particular point in the system. The derivative u0 .t/ then has the interpretation of velocity, and u00.t/ is the associated acceleration. The model (1.1) is not only applicable to vibrating mechanical systems, but also to oscillations in electrical circuits.

#### **1.1.2 A Centered Finite Difference Scheme**

To formulate a finite difference method for the model problem (1.1), we follow the four steps explained in Section 1.1.2 in [9].

**Step 1: Discretizing the domain** The domain is discretized by introducing a uniformly partitioned time mesh. The points in the mesh are tn <sup>D</sup> nt, <sup>n</sup> <sup>D</sup> 0; 1; : : : ; Nt , where t <sup>D</sup> T =Nt is the constant length of the time steps. We introduce a mesh function <sup>u</sup><sup>n</sup> for <sup>n</sup> <sup>D</sup> 0; 1; : : : ; Nt , which approximates the exact solution at the mesh points. (Note that <sup>n</sup> D <sup>0</sup> is the known initial condition, so u<sup>n</sup> is identical to the mathematical u at this point.) The mesh function u<sup>n</sup> will be computed from algebraic equations derived from the differential equation problem.

**Step 2: Fulfilling the equation at discrete time points** The ODE is to be satisfied at each mesh point where the solution must be found:

$$
\mu''(t\_n) + \alpha^2 \mu(t\_n) = 0, \quad n = 1, \ldots, N\_l \,. \tag{1.3}
$$

**Step 3: Replacing derivatives by finite differences** The derivative u00.tn/ is to be replaced by a finite difference approximation. A common second-order accurate approximation to the second-order derivative is

$$
\mu''(t\_n) \approx \frac{u^{n+1} - 2u^n + u^{n-1}}{\Delta t^2} \,. \tag{1.4}
$$

Inserting (1.4) in (1.3) yields

$$\frac{u^{n+1} - 2u^n + u^{n-1}}{\Delta t^2} = -\alpha^2 u^n. \tag{1.5}$$

We also need to replace the derivative in the initial condition by a finite difference. Here we choose a centered difference, whose accuracy is similar to the centered difference we used for u00:

$$\frac{u^1 - u^{-1}}{2\Delta t} = 0 \,. \tag{1.6}$$

**Step 4: Formulating a recursive algorithm** To formulate the computational algorithm, we assume that we have already computed u<sup>n</sup><sup>1</sup> and u<sup>n</sup>, such that u<sup>n</sup>C<sup>1</sup> is the unknown value to be solved for:

$$u^{n+1} = 2u^n - u^{n-1} - \Delta t^2 \alpha^2 u^n. \tag{1.7}$$

The computational algorithm is simply to apply (1.7) successively for <sup>n</sup> D 1; 2; : : : ; Nt 1. This numerical scheme sometimes goes under the name Störmer's method, Verlet integration1, or the Leapfrog method (one should note that Leapfrog is used for many quite different methods for quite different differential equations!).

**Computing the first step** We observe that (1.7) cannot be used for <sup>n</sup> D <sup>0</sup> since the computation of <sup>u</sup><sup>1</sup> then involves the undefined value <sup>u</sup><sup>1</sup> at <sup>t</sup> D t. The discretization of the initial condition then comes to our rescue: (1.6) implies <sup>u</sup><sup>1</sup> D <sup>u</sup><sup>1</sup> and this relation can be combined with (1.7) for <sup>n</sup> D <sup>0</sup> to yield a value for <sup>u</sup><sup>1</sup>:

$$u^1 = 2u^0 - u^1 - \Delta t^2 w^2 u^0,$$

which reduces to

$$u^1 = u^0 - \frac{1}{2} \Delta t^2 \omega^2 u^0. \tag{1.8}$$

Exercise 1.5 asks you to perform an alternative derivation and also to generalize the initial condition to u<sup>0</sup> .0/ D <sup>V</sup> ¤ <sup>0</sup>.

#### **The computational algorithm** The steps for solving (1.1) become


The algorithm is more precisely expressed directly in Python:

```
t = linspace(0, T, Nt+1) # mesh points in time
dt = t[1] - t[0] # constant time step
u = zeros(Nt+1) # solution
u[0] = I
u[1] = u[0] - 0.5*dt**2*w**2*u[0]
for n in range(1, Nt):
   u[n+1] = 2*u[n] - u[n-1] - dt**2*w**2*u[n]
```
## **Remark on using** <sup>w</sup> **for** ! **in computer code**

In the code, we use w as the symbol for !. The reason is that the authors prefer w for readability and comparison with the mathematical ! instead of the full word omega as variable name.

<sup>1</sup> http://en.wikipedia.org/wiki/Verlet\_integration

**Operator notation** We may write the scheme using a compact difference notation listed in Appendix A.1 (see also Section 1.1.8 in [9]). The difference (1.4) has the operator notation ŒDtDtu<sup>n</sup> such that we can write:

$$[D\_t D\_t u + \omega^2 u = 0]^n. \tag{1.9}$$

Note that ŒDtDtu<sup>n</sup> means applying a central difference with step t=2 twice:

$$[D\_l(D\_l u)]^n = \frac{[D\_l u]^{n + \frac{1}{2}} - [D\_l u]^{n - \frac{1}{2}}}{\Delta t}$$

which is written out as

$$\frac{1}{\Delta t} \left( \frac{u^{n+1} - u^n}{\Delta t} - \frac{u^n - u^{n-1}}{\Delta t} \right) = \frac{u^{n+1} - 2u^n + u^{n-1}}{\Delta t^2}.$$

The discretization of initial conditions can in the operator notation be expressed as

$$\left[u=I\right]^0, \quad \left[D\_{2l}u=0\right]^0,\tag{1.10}$$

where the operator ŒD2tu<sup>n</sup> is defined as

$$[D\_{2l}u]^n = \frac{u^{n+1} - u^{n-1}}{2\Delta t}.\tag{1.11}$$

#### **1.2 Implementation**

#### **1.2.1 Making a Solver Function**

The algorithm from the previous section is readily translated to a complete Python function for computing and returning u0; u1;:::;uNt and t0; t1;:::;tNt , given the input I , !, t, and T :

```
import numpy as np
import matplotlib.pyplot as plt
def solver(I, w, dt, T):
    """
    Solve u'' + w**2*u = 0 for t in (0,T], u(0)=I and u'(0)=0,
    by a central finite difference method with time step dt.
    """
    dt = float(dt)
    Nt = int(round(T/dt))
    u = np.zeros(Nt+1)
    t = np.linspace(0, Nt*dt, Nt+1)
    u[0] = I
    u[1] = u[0] - 0.5*dt**2*w**2*u[0]
    for n in range(1, Nt):
        u[n+1] = 2*u[n] - u[n-1] - dt**2*w**2*u[n]
    return u, t
```
We have imported numpy and matplotlib under the names np and plt, respectively, as this is very common in the Python scientific computing community and a good programming habit (since we explicitly see where the different functions come from). An alternative is to do from numpy import \* and a similar "import all" for Matplotlib to avoid the np and plt prefixes and make the code as close as possible to MATLAB. (See Section 5.1.4 in [9] for a discussion of the two types of import in Python.)

A function for plotting the numerical and the exact solution is also convenient to have:

```
def u_exact(t, I, w):
    return I*np.cos(w*t)
def visualize(u, t, I, w):
    plt.plot(t, u, 'r--o')
    t_fine = np.linspace(0, t[-1], 1001) # very fine mesh for u_e
    u_e = u_exact(t_fine, I, w)
    plt.hold('on')
    plt.plot(t_fine, u_e, 'b-')
    plt.legend(['numerical', 'exact'], loc='upper left')
    plt.xlabel('t')
    plt.ylabel('u')
    dt = t[1] - t[0]
    plt.title('dt=%g' % dt)
    umin = 1.2*u.min(); umax = -umin
    plt.axis([t[0], t[-1], umin, umax])
    plt.savefig('tmp1.png'); plt.savefig('tmp1.pdf')
```
A corresponding main program calling these functions to simulate a given number of periods (num\_periods) may take the form

```
I = 1
w = 2*pi
dt = 0.05
num_periods = 5
P = 2*pi/w # one period
T = P*num_periods
u, t = solver(I, w, dt, T)
visualize(u, t, I, w, dt)
```
Adjusting some of the input parameters via the command line can be handy. Here is a code segment using the ArgumentParser tool in the argparse module to define option value (–option value) pairs on the command line:

```
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--I', type=float, default=1.0)
parser.add_argument('--w', type=float, default=2*pi)
parser.add_argument('--dt', type=float, default=0.05)
parser.add_argument('--num_periods', type=int, default=5)
a = parser.parse_args()
I, w, dt, num_periods = a.I, a.w, a.dt, a.num_periods
```
Such parsing of the command line is explained in more detail in Section 5.2.3 in [9].

A typical execution goes like

**Computing** u<sup>0</sup> In mechanical vibration applications one is often interested in computing the velocity v.t/ D <sup>u</sup><sup>0</sup> .t/ after u.t/ has been computed. This can be done by a central difference,

$$\upsilon(t\_n) = u'(t\_n) \approx v^n = \frac{u^{n+1} - u^{n-1}}{2\Delta t} = [D\_{2l}u]^n. \tag{1.12}$$

This formula applies for all inner mesh points, <sup>n</sup> <sup>D</sup> 1; : : : ; Nt <sup>1</sup>. For <sup>n</sup> <sup>D</sup> <sup>0</sup>, v.0/ is given by the initial condition on u<sup>0</sup> .0/, and for <sup>n</sup> <sup>D</sup> Nt we can use a one-sided, backward difference:

$$v^n = [D\_t^- u]^n = \frac{u^n - u^{n-1}}{\Delta t}.$$

Typical (scalar) code is

```
v = np.zeros_like(u) # or v = np.zeros(len(u))
# Use central difference for internal points
for i in range(1, len(u)-1):
    v[i]=(u[i+1] - u[i-1])/(2*dt)
# Use initial condition for u'(0) when i=0
v[0] = 0
# Use backward difference at the final mesh point
v[-1] = (u[-1] - u[-2])/dt
```
Since the loop is slow for large Nt , we can get rid of the loop by vectorizing the central difference. The above code segment goes as follows in its vectorized version (see Problem 1.2 in [9] for explanation of details):

```
v = np.zeros_like(u)
v[1:-1] = (u[2:] - u[:-2])/(2*dt) # central difference
v[0] = 0 # boundary condition u'(0)
v[-1] = (u[-1] - u[-2])/dt # backward difference
```
#### **1.2.2 Verification**

**Manual calculation** The simplest type of verification, which is also instructive for understanding the algorithm, is to compute u<sup>1</sup>, u<sup>2</sup>, and u<sup>3</sup> with the aid of a calculator and make a function for comparing these results with those from the solver function. The test\_three\_steps function in the file vib\_undamped.py shows the details of how we use the hand calculations to test the code:

```
def test_three_steps():
    from math import pi
    I = 1; w = 2*pi; dt = 0.1; T = 1
    u_by_hand = np.array([1.000000000000000,
                          0.802607911978213,
                          0.288358920740053])
    u, t = solver(I, w, dt, T)
    diff = np.abs(u_by_hand - u[:3]).max()
    tol = 1E-14
    assert diff < tol
```
This function is a proper *test function*, compliant with the pytest and nose testing framework for Python code, because


We shall in this book implement all software verification via such proper test functions, also known as unit testing. See Section 5.3.2 in [9] for more details on how to construct test functions and utilize nose or pytest for automatic execution of tests. Our recommendation is to use pytest. With this choice, you can run all test functions in vib\_undamped.py by

```
Terminal
Terminal> py.test -s -v vib_undamped.py
============================= test session starts ======...
platform linux2 -- Python 2.7.9 -- ...
collected 2 items
vib_undamped.py::test_three_steps PASSED
vib_undamped.py::test_convergence_rates PASSED
=========================== 2 passed in 0.19 seconds ===...
```
**Testing very simple polynomial solutions** Constructing test problems where the exact solution is constant or linear helps initial debugging and verification as one expects any reasonable numerical method to reproduce such solutions to machine precision. Second-order accurate methods will often also reproduce a quadratic solution. Here ŒDtDt t 2 n D <sup>2</sup>, which is the exact result. A solution <sup>u</sup> D <sup>t</sup> <sup>2</sup> leads to <sup>u</sup><sup>00</sup> C !2u D <sup>2</sup> C .!t/2 ¤ <sup>0</sup>. We must therefore add a source in the equation: <sup>u</sup><sup>00</sup> C !2u D <sup>f</sup> to allow a solution <sup>u</sup> D <sup>t</sup> <sup>2</sup> for <sup>f</sup> D <sup>2</sup> C .!t/2. By simple insertion we can show that the mesh function <sup>u</sup><sup>n</sup> D <sup>t</sup> <sup>2</sup> <sup>n</sup> is also a solution of the discrete equations. Problem 1.1 asks you to carry out all details to show that linear and quadratic solutions are solutions of the discrete equations. Such results are very useful for debugging and verification. You are strongly encouraged to do this problem now!

**Checking convergence rates** Empirical computation of convergence rates yields a good method for verification. The method and its computational details are explained in detail in Section 3.1.6 in [9]. Readers not familiar with the concept should look up this reference before proceeding.

In the present problem, computing convergence rates means that we must


The computed rates r0; r1;:::;rm<sup>2</sup> hopefully converge to the number 2 in the present problem, because theory (from Sect. 1.4) shows that the error of the numerical method we use behaves like t <sup>2</sup>. The convergence of the sequence r0; r1;:::;rm<sup>2</sup> demands that the time steps ti are sufficiently small for the error model Ei <sup>D</sup> C.ti/r to be valid.

All the implementational details of computing the sequence r0; r1;:::;rm<sup>2</sup> appear below.

```
def convergence_rates(m, solver_function, num_periods=8):
   """
   Return m-1 empirical estimates of the convergence rate
   based on m simulations, where the time step is halved
   for each simulation.
   solver_function(I, w, dt, T) solves each problem, where T
   is based on simulation for num_periods periods.
   """
   from math import pi
   w = 0.35; I = 0.3 # just chosen values
   P = 2*pi/w # period
   dt = P/30 # 30 time step per period 2*pi/w
   T = P*num_periods
   dt_values = []
   E_values = []
   for i in range(m):
       u, t = solver_function(I, w, dt, T)
       u_e = u_exact(t, I, w)
       E = np.sqrt(dt*np.sum((u_e-u)**2))
       dt_values.append(dt)
       E_values.append(E)
       dt = dt/2
   r = [np.log(E_values[i-1]/E_values[i])/
        np.log(dt_values[i-1]/dt_values[i])
        for i in range(1, m, 1)]
   return r, E_values, dt_values
```
The error analysis in Sect. 1.4 is quite detailed and suggests that <sup>r</sup> D <sup>2</sup>. It is also a intuitively reasonable result, since we used a second-order accurate finite difference approximation ŒDtDtu<sup>n</sup> to the ODE and a second-order accurate finite difference formula for the initial condition for u<sup>0</sup> .

In the present problem, when t0 corresponds to 30 time steps per period, the returned r list has all its values equal to 2.00 (if rounded to two decimals). This amazingly accurate result means that all ti values are well into the asymptotic regime where the error model Ei <sup>D</sup> C.ti/r is valid.

We can now construct a proper test function that computes convergence rates and checks that the final (and usually the best) estimate is sufficiently close to 2. Here, a rough tolerance of 0.1 is enough. Later, we will argue for an improvement by adjusting omega and include also that case in our test function here. The unit test goes like

```
def test_convergence_rates():
    r, E, dt = convergence_rates(
        m=5, solver_function=solver, num_periods=8)
    # Accept rate to 1 decimal place
    tol = 0.1
    assert abs(r[-1] - 2.0) < tol
    # Test that adjusted w obtains 4th order convergence
    r, E, dt = convergence_rates(
        m=5, solver_function=solver_adjust_w, num_periods=8)
    print 'adjust w rates:', r
    assert abs(r[-1] - 4.0) < tol
```
The complete code appears in the file vib\_undamped.py.

**Visualizing convergence rates with slope markers** Tony S. Yu has written a script plotslopes.py<sup>2</sup> that is very useful to indicate the slope of a graph, especially a graph like ln <sup>E</sup> D <sup>r</sup> ln t C ln<sup>C</sup> arising from the model <sup>E</sup> D Ct <sup>r</sup> . A copy of the script resides in the src/vib<sup>3</sup> directory. Let us use it to compare the original method for <sup>u</sup><sup>00</sup> C !2u D <sup>0</sup> with the same method applied to the equation with a modified !. We make log-log plots of the error versus t. For each curve we attach a slope marker using the slope\_marker((x,y), r) function from plotslopes.py, where (x,y) is the position of the marker and r and the slope (.r; 1/), here (2,1) and (4,1).

```
def plot_convergence_rates():
    r2, E2, dt2 = convergence_rates(
        m=5, solver_function=solver, num_periods=8)
    plt.loglog(dt2, E2)
    r4, E4, dt4 = convergence_rates(
        m=5, solver_function=solver_adjust_w, num_periods=8)
    plt.loglog(dt4, E4)
    plt.legend(['original scheme', r'adjusted $\omega$'],
               loc='upper left')
    plt.title('Convergence of finite difference methods')
    from plotslopes import slope_marker
    slope_marker((dt2[1], E2[1]), (2,1))
    slope_marker((dt4[1], E4[1]), (4,1))
```
<sup>2</sup> http://goo.gl/A4Utm7

<sup>3</sup> http://tinyurl.com/nu656p2/vib

**Fig. 1.1** Empirical convergence rate curves with special slope marker

Figure 1.1 displays the two curves with the markers. The match of the curve slope and the marker slope is excellent.

#### **1.2.3 Scaled Model**

It is advantageous to use dimensionless variables in simulations, because fewer parameters need to be set. The present problem is made dimensionless by introducing dimensionless variables t <sup>N</sup> <sup>D</sup> t=tc and <sup>u</sup><sup>N</sup> <sup>D</sup> u=uc, where tc and uc are characteristic scales for t and u, respectively. We refer to Section 2.2.1 in [11] for all details about this scaling.

The scaled ODE problem reads

$$\frac{u\_c}{t\_c^2} \frac{d^2 \bar{u}}{d\bar{t}^2} + u\_c \bar{u} = 0, \quad u\_c \bar{u}(0) = I,\\ \frac{u\_c}{t\_c} \frac{d\bar{u}}{d\bar{t}}(0) = 0.$$

A common choice is to take tc as one period of the oscillations, tc <sup>D</sup> 2=w, and uc <sup>D</sup> <sup>I</sup> . This gives the dimensionless model

$$\frac{d^2\bar{u}}{d\bar{t}^2} + 4\pi^2\bar{u} = 0, \quad \bar{u}(0) = 1, \ \bar{u}'(0) = 0 \,. \tag{1.13}$$

Observe that there are no physical parameters in (1.13)! We can therefore perform a single numerical simulation u.N t /<sup>N</sup> and afterwards recover any u.tI !;I/ by

$$u(t; \boldsymbol{\alpha}, I) = u\_c \bar{u}(t/t\_c) = I \bar{u}(\boldsymbol{\alpha}t/(2\pi))\,.$$

We can easily check this assertion: the solution of the scaled problem is u.N t /<sup>N</sup> D cos.2t/<sup>N</sup> . The formula for <sup>u</sup> in terms of <sup>u</sup>N gives <sup>u</sup> D <sup>I</sup> cos.!t/, which is nothing but the solution of the original problem with dimensions.

The scaled model can be run by calling solver(I=1, w=2\*pi, dt, T). Each period is now 1 and T simply counts the number of periods. Choosing dt as 1./M gives M time steps per period.

#### **1.3 Visualization of Long Time Simulations**

Figure 1.2 shows a comparison of the exact and numerical solution for the scaled model (1.13) with t D 0:1; 0:05. From the plot we make the following observations:


By angular frequency error we mean that the numerical angular frequency differs from the exact !. This is evident by looking at the peaks of the numerical solution: these have incorrect positions compared with the peaks of the exact cosine solution. The effect can be mathematically expressed by writing the numerical solution as <sup>I</sup> cos!t Q , where !Q is not exactly equal to !. Later, we shall mathematically quantify this numerical angular frequency !Q.

#### **1.3.1 Using a Moving Plot Window**

In vibration problems it is often of interest to investigate the system's behavior over long time intervals. Errors in the angular frequency accumulate and become more visible as time grows. We can investigate long time series by introducing a moving plot window that can move along with the p most recently computed periods of the solution. The SciTools4 package contains a convenient tool for this: MovingPlotWindow. Typing pydoc scitools.MovingPlotWindow shows a demo and a description of its use. The function below utilizes the moving plot

**Fig. 1.2** Effect of halving the time step

<sup>4</sup> https://github.com/hplgit/scitools

window and is in fact called by the main function in the vib\_undamped module if the number of periods in the simulation exceeds 10.

```
def visualize_front(u, t, I, w, savefig=False, skip_frames=1):
    """
    Visualize u and the exact solution vs t, using a
    moving plot window and continuous drawing of the
    curves as they evolve in time.
    Makes it easy to plot very long time series.
    Plots are saved to files if savefig is True.
    Only each skip_frames-th plot is saved (e.g., if
    skip_frame=10, only each 10th plot is saved to file;
    this is convenient if plot files corresponding to
    different time steps are to be compared).
    """
    import scitools.std as st
    from scitools.MovingPlotWindow import MovingPlotWindow
    from math import pi
    # Remove all old plot files tmp_*.png
    import glob, os
    for filename in glob.glob('tmp_*.png'):
        os.remove(filename)
    P = 2*pi/w # one period
    umin = 1.2*u.min(); umax = -umin
    dt = t[1] - t[0]
    plot_manager = MovingPlotWindow(
        window_width=8*P,
        dt=dt,
        yaxis=[umin, umax],
        mode='continuous drawing')
    frame_counter = 0
    for n in range(1,len(u)):
        if plot_manager.plot(n):
            s = plot_manager.first_index_in_plot
            st.plot(t[s:n+1], u[s:n+1], 'r-1',
                    t[s:n+1], I*cos(w*t)[s:n+1], 'b-1',
                    title='t=%6.3f' % t[n],
                    axis=plot_manager.axis(),
                    show=not savefig) # drop window if savefig
            if savefig and n % skip_frames == 0:
                filename = 'tmp_%04d.png' % frame_counter
                st.savefig(filename)
                print 'making plot file', filename, 'at t=%g' % t[n]
                frame_counter += 1
        plot_manager.update(n)
```
We run the scaled problem (the default values for the command-line arguments –I and –w correspond to the scaled problem) for 40 periods with 20 time steps per period:

Terminal Terminal> python vib\_undamped.py --dt 0.05 --num\_periods 40 The moving plot window is invoked, and we can follow the numerical and exact solutions as time progresses. From this demo we see that the angular frequency error is small in the beginning, and that it becomes more prominent with time. A new run with t D 0:1 (i.e., only 10 time steps per period) clearly shows that the phase errors become significant even earlier in the time series, deteriorating the solution further.

#### **1.3.2 Making Animations**

**Producing standard video formats** The visualize\_front function stores all the plots in files whose names are numbered: tmp\_0000.png, tmp\_0001.png, tmp\_0002.png, and so on. From these files we may make a movie. The Flash format is popular,

Terminal Terminal> ffmpeg -r 25 -i tmp\_%04d.png -c:v flv movie.flv

The ffmpeg program can be replaced by the avconv program in the above command if desired (but at the time of this writing it seems to be more momentum in the ffmpeg project). The -r option should come first and describes the number of frames per second in the movie (even if we would like to have slow movies, keep this number as large as 25, otherwise files are skipped from the movie). The -i option describes the name of the plot files. Other formats can be generated by changing the video codec and equipping the video file with the right extension:


The video file can be played by some video player like vlc, mplayer, gxine, or totem, e.g.,

Terminal Terminal> vlc movie.webm

A web page can also be used to play the movie. Today's standard is to use the HTML5 video tag:

```
<video autoplay loop controls
       width='640' height='365' preload='none'>
<source src='movie.webm' type='video/webm; codecs="vp8, vorbis"'>
</video>
```
Modern browsers do not support all of the video formats. MP4 is needed to successfully play the videos on Apple devices that use the Safari browser. WebM is the preferred format for Chrome, Opera, Firefox, and Internet Explorer v9+. Flash was a popular format, but older browsers that required Flash can play MP4. All browsers that work with Ogg can also work with WebM. This means that to have a video work in all browsers, the video should be available in the MP4 and WebM formats. The proper HTML code reads

```
<video autoplay loop controls
       width='640' height='365' preload='none'>
<source src='movie.mp4' type='video/mp4;
 codecs="avc1.42E01E, mp4a.40.2"'>
<source src='movie.webm' type='video/webm;
 codecs="vp8, vorbis"'>
</video>
```
The MP4 format should appear first to ensure that Apple devices will load the video correctly.

#### **Caution: number the plot files correctly**

To ensure that the individual plot frames are shown in correct order, it is important to number the files with zero-padded numbers (0000, 0001, 0002, etc.). The printf format %04d specifies an integer in a field of width 4, padded with zeros from the left. A simple Unix wildcard file specification like tmp\_\*.png will then list the frames in the right order. If the numbers in the filenames were not zero-padded, the frame tmp\_11.png would appear before tmp\_2.png in the movie.

**Playing PNG files in a web browser** The scitools movie command can create a movie player for a set of PNG files such that a web browser can be used to watch the movie. This interface has the advantage that the speed of the movie can easily be controlled, a feature that scientists often appreciate. The command for creating an HTML with a player for a set of PNG files tmp\_\*.png goes like

The fps argument controls the speed of the movie ("frames per second").

To watch the movie, load the video file vib.html into some browser, e.g.,

Terminal Terminal> google-chrome vib.html # invoke web page Click on Start movie to see the result. Moving this movie to some other place requires moving vib.html *and all the PNG files* tmp\_\*.png:

Terminal Terminal> mkdir vib\_dt0.1 Terminal> mv tmp\_\*.png vib\_dt0.1 Terminal> mv vib.html vib\_dt0.1/index.html

**Making animated GIF files** The convert program from the ImageMagick software suite can be used to produce animated GIF files from a set of PNG files:

Terminal Terminal> convert -delay 25 tmp\_vib\*.png tmp\_vib.gif

The -delay option needs an argument of the delay between each frame, measured in 1/100 s, so 4 frames/s here gives 25/100 s delay. Note, however, that in this particular example with t D 0:05 and 40 periods, making an animated GIF file out of the large number of PNG files is a very heavy process and not considered feasible. Animated GIFs are best suited for animations with not so many frames and where you want to see each frame and play them slowly.

#### **1.3.3 Using Bokeh to Compare Graphs**

Instead of a moving plot frame, one can use tools that allow panning by the mouse. For example, we can show four periods of several signals in several plots and then scroll with the mouse through the rest of the simulation *simultaneously* in all the plot windows. The Bokeh<sup>5</sup> plotting library offers such tools, but the plots must be displayed in a web browser. The documentation of Bokeh is excellent, so here we just show how the library can be used to compare a set of u curves corresponding to long time simulations. (By the way, the guidance to correct pronunciation of Bokeh in the documentation<sup>6</sup> and on Wikipedia7 is not directly compatible with a YouTube video<sup>8</sup> . . . ).

Imagine we have performed experiments for a set of t values. We want each curve, together with the exact solution, to appear in a plot, and then arrange all plots in a grid-like fashion:

<sup>5</sup> http://bokeh.pydata.org/en/latest

<sup>6</sup> http://bokeh.pydata.org/en/0.10.0/docs/faq.html#how-do-you-pronounce-bokeh

<sup>7</sup> https://en.wikipedia.org/wiki/Bokeh

<sup>8</sup> https://www.youtube.com/watch?v=OR8HSHevQTM

Furthermore, we want the axes to couple such that if we move into the future in one plot, all the other plots follows (note the displaced t axes!):

A function for creating a Bokeh plot, given a list of u arrays and corresponding t arrays, is implemented below. The code combines data from different simulations, described compactly in a list of strings legends.

```
def bokeh_plot(u, t, legends, I, w, t_range, filename):
    """
    Make plots for u vs t using the Bokeh library.
    u and t are lists (several experiments can be compared).
    legens contain legend strings for the various u,t pairs.
    """
    if not isinstance(u, (list,tuple)):
        u = [u] # wrap in list
    if not isinstance(t, (list,tuple)):
        t = [t] # wrap in list
    if not isinstance(legends, (list,tuple)):
        legends = [legends] # wrap in list
    import bokeh.plotting as plt
    plt.output_file(filename, mode='cdn', title='Comparison')
    # Assume that all t arrays have the same range
    t_fine = np.linspace(0, t[0][-1], 1001) # fine mesh for u_e
    tools = 'pan,wheel_zoom,box_zoom,reset,'\
            'save,box_select,lasso_select'
    u_range = [-1.2*I, 1.2*I]
    font_size = '8pt'
    p = [] # list of plot objects
    # Make the first figure
    p_ = plt.figure(
        width=300, plot_height=250, title=legends[0],
        x_axis_label='t', y_axis_label='u',
        x_range=t_range, y_range=u_range, tools=tools,
        title_text_font_size=font_size)
    p_.xaxis.axis_label_text_font_size=font_size
    p_.yaxis.axis_label_text_font_size=font_size
    p_.line(t[0], u[0], line_color='blue')
    # Add exact solution
    u_e = u_exact(t_fine, I, w)
    p_.line(t_fine, u_e, line_color='red', line_dash='4 4')
    p.append(p_)
    # Make the rest of the figures and attach their axes to
    # the first figure's axes
    for i in range(1, len(t)):
        p_ = plt.figure(
            width=300, plot_height=250, title=legends[i],
            x_axis_label='t', y_axis_label='u',
            x_range=p[0].x_range, y_range=p[0].y_range, tools=tools,
            title_text_font_size=font_size)
        p_.xaxis.axis_label_text_font_size = font_size
        p_.yaxis.axis_label_text_font_size = font_size
        p_.line(t[i], u[i], line_color='blue')
        p_.line(t_fine, u_e, line_color='red', line_dash='4 4')
        p.append(p_)
    # Arrange all plots in a grid with 3 plots per row
    grid = [[]]
    for i, p_ in enumerate(p):
        grid[-1].append(p_)
        if (i+1) % 3 == 0:
            # New row
            grid.append([])
    plot = plt.gridplot(grid, toolbar_location='left')
    plt.save(plot)
    plt.show(plot)
```
A particular example using the bokeh\_plot function appears below.

```
def demo_bokeh():
   """Solve a scaled ODE u'' + u = 0."""
   from math import pi
   w = 1.0 # Scaled problem (frequency)
   P = 2*np.pi/w # Period
   num_steps_per_period = [5, 10, 20, 40, 80]
   T = 40*P # Simulation time: 40 periods
   u = [] # List of numerical solutions
   t = [] # List of corresponding meshes
   legends = []
   for n in num_steps_per_period:
       dt = P/n
       u_, t_ = solver(I=1, w=w, dt=dt, T=T)
       u.append(u_)
       t.append(t_)
       legends.append('# time steps per period: %d' % n)
   bokeh_plot(u, t, legends, I=1, w=w, t_range=[0, 4*P],
              filename='tmp.html')
```
#### **1.3.4 Using a Line-by-Line Ascii Plotter**

Plotting functions vertically, line by line, in the terminal window using ascii characters only is a simple, fast, and convenient visualization technique for long time series. Note that the time axis then is positive downwards on the screen, so we can let the solution be visualized "forever". The tool scitools.avplotter.Plotter makes it easy to create such plots:

```
def visualize_front_ascii(u, t, I, w, fps=10):
    """
    Plot u and the exact solution vs t line by line in a
    terminal window (only using ascii characters).
    Makes it easy to plot very long time series.
    """
    from scitools.avplotter import Plotter
    import time
    from math import pi
    P = 2*pi/w
    umin = 1.2*u.min(); umax = -umin
    p = Plotter(ymin=umin, ymax=umax, width=60, symbols='+o')
    for n in range(len(u)):
        print p.plot(t[n], u[n], I*cos(w*t[n])), \
              '%.1f' % (t[n]/P)
        time.sleep(1/float(fps))
```
The call p.plot returns a line of text, with the t axis marked and a symbol + for the first function (u) and o for the second function (the exact solution). Here we append to this text a time counter reflecting how many periods the current time point corresponds to. A typical output (! D 2, t D 0:05) looks like this:

#### **1.3.5 Empirical Analysis of the Solution**

For oscillating functions like those in Fig. 1.2 we may compute the amplitude and frequency (or period) empirically. That is, we run through the discrete solution points .tn; un/ and find all maxima and minima points. The distance between two consecutive maxima (or minima) points can be used as estimate of the local period, while half the difference between the u value at a maximum and a nearby minimum gives an estimate of the local amplitude.

The local maxima are the points where

$$u^{n-1} < u^n > u^{n+1}, \quad n = 1, \ldots, N\_l - 1,\tag{1.14}$$

and the local minima are recognized by

$$u^{n-1} > u^n < u^{n+1}, \quad n = 1, \ldots, N\_l - 1 \,. \tag{1.15}$$

In computer code this becomes

```
def minmax(t, u):
    minima = []; maxima = []
    for n in range(1, len(u)-1, 1):
        if u[n-1] > u[n] < u[n+1]:
            minima.append((t[n], u[n]))
        if u[n-1] < u[n] > u[n+1]:
            maxima.append((t[n], u[n]))
    return minima, maxima
```
Note that the two returned objects are lists of tuples.

Let .ti ; ei/, <sup>i</sup> <sup>D</sup> 0; : : : ; M <sup>1</sup>, be the sequence of all the <sup>M</sup> maxima points, where ti is the time value and ei the corresponding u value. The local period can be defined as pi <sup>D</sup> tiC<sup>1</sup> ti . With Python syntax this reads

```
def periods(maxima):
    p = [extrema[n][0] - maxima[n-1][0]
         for n in range(1, len(maxima))]
    return np.array(p)
```
The list p created by a list comprehension is converted to an array since we probably want to compute with it, e.g., find the corresponding frequencies 2\*pi/p.

Having the minima and the maxima, the local amplitude can be calculated as the difference between two neighboring minimum and maximum points:

```
def amplitudes(minima, maxima):
    a = [(abs(maxima[n][1] - minima[n][1]))/2.0
         for n in range(min(len(minima),len(maxima)))]
    return np.array(a)
```
The code segments are found in the file vib\_empirical\_analysis.py.

Since a[i] and p[i] correspond to the i-th amplitude estimate and the i-th period estimate, respectively, it is most convenient to visualize the a and p values with the index i on the horizontal axis. (There is no unique time point associated with either of these estimate since values at two different time points were used in the computations.)

In the analysis of very long time series, it is advantageous to compute and plot p and a instead of u to get an impression of the development of the oscillations. Let us do this for the scaled problem and t D 0:1; 0:05; 0:01. A ready-made function

plot\_empirical\_freq\_and\_amplitude(u, t, I, w)

computes the empirical amplitudes and periods, and creates a plot where the amplitudes and angular frequencies are visualized together with the exact amplitude I and the exact angular frequency w. We can make a little program for creating the plot:

```
from vib_undamped import solver, plot_empirical_freq_and_amplitude
from math import pi
dt_values = [0.1, 0.05, 0.01]
u_cases = []
t_cases = []
for dt in dt_values:
    # Simulate scaled problem for 40 periods
    u, t = solver(I=1, w=2*pi, dt=dt, T=40)
    u_cases.append(u)
    t_cases.append(t)
plot_empirical_freq_and_amplitude(u_cases, t_cases, I=1, w=2*pi)
```
**Fig. 1.3** Empirical angular frequency (*left*) and amplitude (*right*) for three different time steps

Figure 1.3 shows the result: we clearly see that lowering t improves the angular frequency significantly, while the amplitude seems to be more accurate. The lines with t D 0:01, corresponding to 100 steps per period, can hardly be distinguished from the exact values. The next section shows how we can get mathematical insight into why amplitudes are good while frequencies are more inaccurate.

#### **1.4 Analysis of the Numerical Scheme**

#### **1.4.1 Deriving a Solution of the Numerical Scheme**

After having seen the phase error grow with time in the previous section, we shall now quantify this error through mathematical analysis. The key tool in the analysis will be to establish an exact solution of the discrete equations. The difference equation (1.7) has constant coefficients and is homogeneous. Such equations are known to have solutions on the form <sup>u</sup><sup>n</sup> D CAn, where <sup>A</sup> is some number to be determined from the difference equation and <sup>C</sup> is found as the initial condition (<sup>C</sup> D <sup>I</sup> ). Recall that n in u<sup>n</sup> is a superscript labeling the time level, while n in An is an exponent.

With oscillating functions as solutions, the algebra will be considerably simplified if we seek an A on the form

$$A = e^{i\tilde{\alpha}\Delta t},$$

and solve for the numerical frequency !Q rather than <sup>A</sup>. Note that <sup>i</sup> D p<sup>1</sup> is the imaginary unit. (Using a complex exponential function gives simpler arithmetics than working with a sine or cosine function.) We have

$$A^n = e^{i\tilde{\alpha}\Delta t \cdot n} = e^{i\tilde{\alpha}t\_n} = \cos(\tilde{\alpha}t\_n) + i\sin(\tilde{\alpha}t\_n) \ .$$

The physically relevant numerical solution can be taken as the real part of this complex expression.

The calculations go as

$$\begin{split} [D\_t D\_t u]^n &= \frac{u^{n+1} - 2u^n + u^{n-1}}{\Delta t^2} \\ &= I \frac{A^{n+1} - 2A^n + A^{n-1}}{\Delta t^2} \\ &= \frac{I}{\Delta t^2} \left( e^{i\tilde{\phi}(t\_n + \Delta t)} - 2e^{i\tilde{\phi}l\_n} + e^{i\tilde{\phi}(l\_n - \Delta t)} \right) \\ &= I e^{i\tilde{\phi}l\_n} \frac{1}{\Delta t^2} \left( e^{i\tilde{\phi}\Delta t} + e^{i\tilde{\phi}(-\Delta t)} - 2 \right) \\ &= I e^{i\tilde{\phi}l\_n} \frac{2}{\Delta t^2} \left( \cosh(i\tilde{\phi}\Delta t) - 1 \right) \\ &= I e^{i\tilde{\phi}l\_n} \frac{2}{\Delta t^2} \left( \cos(\tilde{\phi}\Delta t) - 1 \right) \\ &= -I e^{i\tilde{\phi}l\_n} \frac{4}{\Delta t^2} \sin^2 \left( \frac{\tilde{\phi}\Delta t}{2} \right). \end{split}$$

The last line follows from the relation cos <sup>x</sup> <sup>1</sup> D <sup>2</sup> sin<sup>2</sup> .x=2/ (try cos(x)-1 in wolframalpha.com9 to see the formula).

The scheme (1.7) with <sup>u</sup><sup>n</sup> D Iei!t n <sup>Q</sup> inserted now gives

$$-I e^{i\tilde{\omega}t\_n} \frac{4}{\Delta t^2} \sin^2 \left(\frac{\tilde{\omega}\Delta t}{2}\right) + \omega^2 I e^{i\tilde{\omega}t\_n} = 0,\tag{1.16}$$

which after dividing by Iei!t <sup>Q</sup> <sup>n</sup> results in

$$\frac{4}{\Delta t^2} \sin^2 \left(\frac{\tilde{\omega}\,\Delta t}{2}\right) = \omega^2. \tag{1.17}$$

The first step in solving for the unknown !Q is

$$
\sin^2\left(\frac{\tilde{\alpha}\Delta t}{2}\right) = \left(\frac{\alpha\Delta t}{2}\right)^2 \cdot \frac{1}{4}
$$

Then, taking the square root, applying the inverse sine function, and multiplying by 2=t, results in

$$
\tilde{\omega} = \pm \frac{2}{\Delta t} \sin^{-1} \left( \frac{\alpha \Delta t}{2} \right) \,. \tag{1.18}
$$

#### **1.4.2 The Error in the Numerical Frequency**

The first observation following (1.18) tells that there is a phase error since the numerical frequency !Q never equals the exact frequency !. But how good is the approximation (1.18)? That is, what is the error ! Q! or !=! Q ? Taylor series expansion for small t may give an expression that is easier to understand than the complicated function in (1.18):

<sup>9</sup> http://www.wolframalpha.com

```
>>> from sympy import *
>>> dt, w = symbols('dt w')
>>> w_tilde_e = 2/dt*asin(w*dt/2)
>>> w_tilde_series = w_tilde_e.series(dt, 0, 4)
>>> print w_tilde_series
w + dt**2*w**3/24 + O(dt**4)
```
This means that

$$
\tilde{\omega} = \omega \left( 1 + \frac{1}{24} \omega^2 \Delta t^2 \right) + \mathcal{O}(\Delta t^4) \,. \tag{1.19}
$$

The error in the numerical frequency is of second-order in t, and the error vanishes as t ! <sup>0</sup>. We see that !>! <sup>Q</sup> since the term !3t 2=24 > 0 and this is by far the biggest term in the series expansion for small !t. A numerical frequency that is too large gives an oscillating curve that oscillates too fast and therefore "lags behind" the exact oscillations, a feature that can be seen in the left plot in Fig. 1.2.

Figure 1.4 plots the discrete frequency (1.18) and its approximation (1.19) for ! D <sup>1</sup> (based on the program vib\_plot\_freq.py). Although !Q is a function of t in (1.19), it is misleading to think of t as the important discretization parameter. It is the product !t that is the key discretization parameter. This quantity reflects the *number of time steps per period* of the oscillations. To see this, we set <sup>P</sup> <sup>D</sup> NP t, where P is the length of a period, and NP is the number of time steps during a period. Since <sup>P</sup> and ! are related by <sup>P</sup> <sup>D</sup> 2=!, we get that !t <sup>D</sup> 2=NP , which shows that !t is directly related to NP .

The plot shows that at least NP <sup>25</sup> <sup>30</sup> points per period are necessary for reasonable accuracy, but this depends on the length of the simulation (T ) as the total phase error due to the frequency error grows linearly with time (see Exercise 1.2).

**Fig. 1.4** Exact discrete frequency and its second-order series expansion

# **1.4.3 Empirical Convergence Rates and Adjusted** !

The expression (1.19) suggests that adjusting omega to

$$
\alpha \left( 1 - \frac{1}{24} \alpha^2 \Delta t^2 \right),
$$

could have effect on the *convergence rate* of the global error in u (cf. Sect. 1.2.2). With the convergence\_rates function in vib\_undamped.py we can easily check this. A special solver, with adjusted w, is available as the function solver\_adjust\_w. A call to convergence\_rates with this solver reveals that the rate is 4.0! With the original, physical ! the rate is 2.0 – as expected from using second-order finite difference approximations, as expected from the forthcoming derivation of the global error, and as expected from truncation error analysis analysis as explained in Appendix B.4.1.

Adjusting ! is an ideal trick for this simple problem, but when adding damping and nonlinear terms, we have no simple formula for the impact on !, and therefore we cannot use the trick.

#### **1.4.4 Exact Discrete Solution**

Perhaps more important than the !Q D ! C *<sup>O</sup>*.t 2/ result found above is the fact that we have an exact discrete solution of the problem:

$$u^{\prime} = I \cos\left(\tilde{\omega}n\Delta t\right), \quad \tilde{\omega} = \frac{2}{\Delta t} \sin^{-1}\left(\frac{\alpha\Delta t}{2}\right). \tag{1.20}$$

We can then compute the error mesh function

$$e^n = u\_\mathfrak{C}(t\_n) - u^n = I\cos\left(\omega n \Delta t\right) - I\cos\left(\tilde{\alpha}n\Delta t\right) \,. \tag{1.21}$$

From the formula cos 2x cos 2y D <sup>2</sup> sin.x y/sin.x C y/ we can rewrite <sup>e</sup><sup>n</sup> so the expression is easier to interpret:

$$e'' = -2I\sin\left(t\frac{1}{2}\left(\omega - \tilde{\omega}\right)\right)\sin\left(t\frac{1}{2}\left(\omega + \tilde{\omega}\right)\right). \tag{1.22}$$

The error mesh function is ideal for verification purposes and you are strongly encouraged to make a test based on (1.20) by doing Exercise 1.11.

#### **1.4.5 Convergence**

We can use (1.19) and (1.21), or (1.22), to show *convergence* of the numerical scheme, i.e., <sup>e</sup><sup>n</sup> ! <sup>0</sup> as t ! <sup>0</sup>, which implies that the numerical solution approaches the exact solution as t approaches to zero. We have that

$$\lim\_{\Delta t \to 0} \tilde{\omega} = \lim\_{\Delta t \to 0} \frac{2}{\Delta t} \sin^{-1} \left( \frac{\omega \Delta t}{2} \right) = \omega,$$

by L'Hopital's rule. This result could also been computed WolframAlpha10, or we could use the limit functionality in sympy:

```
>>> import sympy as sym
>>> dt, w = sym.symbols('x w')
>>> sym.limit((2/dt)*sym.asin(w*dt/2), dt, 0, dir='+')
w
```
Also (1.19) can be used to establish that !Q ! ! when t ! <sup>0</sup>. It then follows from the expression(s) for <sup>e</sup><sup>n</sup> that <sup>e</sup><sup>n</sup> ! <sup>0</sup>.

#### **1.4.6 The Global Error**

To achieve more analytical insight into the nature of the global error, we can Taylor expand the error mesh function (1.21). Since !Q in (1.18) contains t in the denominator we use the series expansion for !Q inside the cosine function. A relevant sympy session is

```
>>> from sympy import *
>>> dt, w, t = symbols('dt w t')
>>> w_tilde_e = 2/dt*asin(w*dt/2)
>>> w_tilde_series = w_tilde_e.series(dt, 0, 4)
>>> w_tilde_series
w + dt**2*w**3/24 + O(dt**4)
```
Series expansions in sympy have the inconvenient O() term that prevents further calculations with the series. We can use the removeO() command to get rid of the O() term:

```
>>> w_tilde_series = w_tilde_series.removeO()
>>> w_tilde_series
dt**2*w**3/24 + w
```
Using this w\_tilde\_series expression for <sup>w</sup>Q in (1.21), dropping <sup>I</sup> (which is a common factor), and performing a series expansion of the error yields

```
>>> error = cos(w*t) - cos(w_tilde_series*t)
>>> error.series(dt, 0, 6)
dt**2*t*w**3*sin(t*w)/24 + dt**4*t**2*w**6*cos(t*w)/1152 + O(dt**6)
```
Since we are mainly interested in the leading-order term in such expansions (the term with lowest power in t, which goes most slowly to zero), we use the .as\_leading\_term(dt) construction to pick out this term:

<sup>10</sup> http://www.wolframalpha.com/input/?i=%282%2Fx%29\*asin%28w\*x%2F2%29+as+x-%3E0

```
>>> error.series(dt, 0, 6).as_leading_term(dt)
dt**2*t*w**3*sin(t*w)/24
```
The last result means that the leading order global (true) error at a point t is proportional to !3tt <sup>2</sup>. Considering only the discrete tn values for t, tn is related to t through tn <sup>D</sup> nt. The factor sin.!t/ can at most be 1, so we use this value to bound the leading-order expression to its maximum value

$$e'' = \frac{1}{24}na^3 \Delta t^3 \dots$$

This is the dominating term of the error *at a point*.

We are interested in the accumulated global error, which can be taken as the `2 norm of e<sup>n</sup>. The norm is simply computed by summing contributions from all mesh points:

$$||e^{\boldsymbol{\mu}}||\_{\ell^2}^2 = \Delta t \sum\_{n=0}^{N\_l} \frac{1}{24^2} n^2 \boldsymbol{\omega}^6 \Delta t^6 = \frac{1}{24^2} \boldsymbol{\omega}^6 \Delta t^\top \sum\_{n=0}^{N\_l} n^2 \Delta t$$

The sum PNt <sup>n</sup>D<sup>0</sup> <sup>n</sup><sup>2</sup> is approximately equal to <sup>1</sup> <sup>3</sup>N <sup>3</sup> <sup>t</sup> . Replacing Nt by T =t and taking the square root gives the expression

$$||e^{n}||\_{\ell^{2}} = \frac{1}{24} \sqrt{\frac{T^{3}}{3}} \omega^{3} \Delta t^{2} \dots$$

This is our expression for the global (or integrated) error. A primary result from this expression is that the global error is proportional to t <sup>2</sup>.

#### **1.4.7 Stability**

Looking at (1.20), it appears that the numerical solution has constant and correct amplitude, but an error in the angular frequency. A constant amplitude is not necessarily the case, however! To see this, note that if only t is large enough, the magnitude of the argument to sin<sup>1</sup> in (1.18) may be larger than 1, i.e., !t=2 > 1. In this case, sin<sup>1</sup> .!t=2/ has a complex value and therefore !Q becomes complex. Type, for example, asin(x) in wolframalpha.com11 to see basic properties of sin1.x/.

A complex !<sup>Q</sup> can be written !<sup>Q</sup> D Q!r <sup>C</sup> i!Q<sup>i</sup> . Since sin1.x/ has a *negative* imaginary part for x>1, !Q<sup>i</sup> < 0, which means that <sup>e</sup>i!t <sup>Q</sup> <sup>D</sup> <sup>e</sup> Q!i <sup>t</sup> ei!Q<sup>r</sup> <sup>t</sup> will lead to exponential growth in time because <sup>e</sup> Q!i <sup>t</sup> with !Q<sup>i</sup> < 0 has a positive exponent.

#### **Stability criterion**

We do not tolerate growth in the amplitude since such growth is not present in the exact solution. Therefore, we must impose a *stability criterion* so that the

<sup>11</sup> http://www.wolframalpha.com

**Fig. 1.5** Growing, unstable solution because of a time step slightly beyond the stability limit

argument in the inverse sine function leads to real and not complex values of !Q. The stability criterion reads

$$\frac{a\Delta t}{2} \le 1 \quad \Rightarrow \quad \Delta t \le \frac{2}{a}.\tag{1.23}$$

With ! <sup>D</sup> 2, t > <sup>1</sup> D 0:3183098861837907 will give growing solutions. Figure 1.5 displays what happens when t D 0:3184, which is slightly above the critical value: t D <sup>1</sup> C 9:01 <sup>10</sup><sup>5</sup>.

#### **1.4.8 About the Accuracy at the Stability Limit**

An interesting question is whether the stability condition t < 2=! is unfortunate, or more precisely: would it be meaningful to take larger time steps to speed up computations? The answer is a clear no. At the stability limit, we have that sin<sup>1</sup> !t=2 D sin<sup>1</sup> <sup>1</sup> D =2, and therefore !Q D =t. (Note that the approximate formula (1.19) is very inaccurate for this value of t as it predicts !Q D 2:34=pi, which is a 25 percent reduction.) The corresponding period of the numerical solution is <sup>P</sup><sup>Q</sup> D 2=!Q D 2t, which means that there is just one time step t between a peak (maximum) and a through<sup>12</sup> (minimum) in the numerical solution. This is the shortest possible wave that can be represented in the mesh! In other words, it is not meaningful to use a larger time step than the stability limit.

Also, the error in angular frequency when t D 2=! is severe: Figure 1.6 shows a comparison of the numerical and analytical solution with ! D 2 and

<sup>12</sup> https://simple.wikipedia.org/wiki/Wave\_(physics)

**Fig. 1.6** Numerical solution with t exactly at the stability limit

t D 2=! D <sup>1</sup>. Already after one period, the numerical solution has a through while the exact solution has a peak (!). The error in frequency when t is at the stability limit becomes ! Q! D !.1 =2/ 0:57!. The corresponding error in the period is <sup>P</sup> <sup>P</sup><sup>Q</sup> 0:36P. The error after <sup>m</sup> periods is then 0:36mP. This error has reached half a period when <sup>m</sup> D 1=.2 0:36/ 1:38, which theoretically confirms the observations in Fig. 1.6 that the numerical solution is a through ahead of a peak already after one and a half period. Consequently, t should be chosen much less than the stability limit to achieve meaningful numerical computations.

#### **Summary**

From the accuracy and stability analysis we can draw three important conclusions:


#### **1.5 Alternative Schemes Based on 1st-Order Equations**

A standard technique for solving second-order ODEs is to rewrite them as a system of first-order ODEs and then choose a solution strategy from the vast collection of methods for first-order ODE systems. Given the second-order ODE problem

$$u'' + \alpha^2 u = 0, \quad u(0) = I, \ u'(0) = 0,$$

we introduce the auxiliary variable <sup>v</sup> D <sup>u</sup><sup>0</sup> and express the ODE problem in terms of first-order derivatives of u and v:

$$u' = v,\tag{1.24}$$

$$v' = -\wp^2 u \,. \tag{1.25}$$

The initial conditions become u.0/ D <sup>I</sup> and v.0/ D <sup>0</sup>.

#### **1.5.1 The Forward Euler Scheme**

A Forward Euler approximation to our <sup>2</sup>-2 system of ODEs (1.24)–(1.25) becomes

$$\left[D\_t^+u = v\right]^n,\tag{1.26}$$

$$[D\_{\iota}^{+}\upsilon = -\omega^{2}u]^{\imath},\tag{1.27}$$

or written out,

$$u^{n+1} = u^n + \Delta t v^n,\tag{1.28}$$

$$v^{n+1} = v^n - \Delta t o^2 u^n. \tag{1.29}$$

Let us briefly compare this Forward Euler method with the centered difference scheme for the second-order differential equation. We have from (1.28) and (1.29) applied at levels <sup>n</sup> and <sup>n</sup> <sup>1</sup> that

$$u^{n+1} = u^n + \Delta t v^n = u^n + \Delta t (v^{n-1} - \Delta t o^2 u^{n-1}) \dots$$

Since from (1.28)

$$v^{n-1} = \frac{1}{\Delta t} (u^n - u^{n-1}),$$

it follows that

$$u^{n+1} = 2u^n - u^{n-1} - \Delta t^2 \alpha^2 u^{n-1},$$

which is very close to the centered difference scheme, but the last term is evaluated at tn<sup>1</sup> instead of tn. Rewriting, so that t 2!2un<sup>1</sup> appears alone on the right-hand side, and then dividing by t <sup>2</sup>, the new left-hand side is an approximation to u<sup>00</sup> at tn, while the right-hand side is sampled at tn<sup>1</sup>. All terms should be sampled at the same mesh point, so using !2un<sup>1</sup> instead of !2un points to a kind of mathematical error in the derivation of the scheme. This error turns out to be rather crucial for the accuracy of the Forward Euler method applied to vibration problems (Sect. 1.5.4 has examples).

The reasoning above does not imply that the Forward Euler scheme is not correct, but more that it is almost equivalent to a second-order accurate scheme for the second-order ODE formulation, and that the error committed has to do with a wrong sampling point.

#### **1.5.2 The Backward Euler Scheme**

A Backward Euler approximation to the ODE system is equally easy to write up in the operator notation:

$$[D\_l^-u = v]^{n+1},\tag{1.30}$$

$$[D\_{\iota}^{-}v = -\omega u]^{n+1} \,. \tag{1.31}$$

This becomes a coupled system for u<sup>n</sup>C<sup>1</sup> and v<sup>n</sup>C<sup>1</sup>:

$$u^{n+1} - \Delta t v^{n+1} = u^n,\tag{1.32}$$

$$
v^{n+1} + \Delta t \boldsymbol{u}^2 \boldsymbol{u}^{n+1} = \boldsymbol{v}^n.\tag{1.33}$$

We can compare (1.32)–(1.33) with the centered scheme (1.7) for the secondorder differential equation. To this end, we eliminate v<sup>n</sup>C<sup>1</sup> in (1.32) using (1.33) solved with respect to v<sup>n</sup>C<sup>1</sup>. Thereafter, we eliminate v<sup>n</sup> using (1.32) solved with respect to <sup>v</sup><sup>n</sup>C<sup>1</sup> and also replacing <sup>n</sup>C<sup>1</sup> by <sup>n</sup> and <sup>n</sup> by <sup>n</sup>1. The resulting equation involving only u<sup>n</sup>C<sup>1</sup>, u<sup>n</sup>, and u<sup>n</sup><sup>1</sup> can be ordered as

$$\frac{u^{n+1} - 2u^n + u^{n-1}}{\Delta t^2} = -a^2 u^{n+1},$$

which has almost the same form as the centered scheme for the second-order differential equation, but the right-hand side is evaluated at u<sup>n</sup>C<sup>1</sup> and not u<sup>n</sup>. This inconsistent sampling of terms has a dramatic effect on the numerical solution, as we demonstrate in Sect. 1.5.4.

#### **1.5.3 The Crank-Nicolson Scheme**

The Crank-Nicolson scheme takes this form in the operator notation:

$$[D\_l u = \overline{v}']^{n + \frac{1}{2}},\tag{1.34}$$

$$[D\_l v = -\omega^2 \overline{u}']^{n + \frac{1}{2}}.\tag{1.35}$$

Writing the equations out and rearranging terms, shows that this is also a coupled system of two linear equations at each time level:

$$
\mu^{n+1} - \frac{1}{2}\Delta t \, v^{n+1} = \mu^n + \frac{1}{2}\Delta t \, v^n,\tag{1.36}
$$

$$
v^{n+1} + \frac{1}{2} \Delta t \boldsymbol{\omega}^2 \boldsymbol{u}^{n+1} = \boldsymbol{v}^n - \frac{1}{2} \Delta t \boldsymbol{\omega}^2 \boldsymbol{u}^n. \tag{1.37}$$

We may compare also this scheme to the centered discretization of the secondorder ODE. It turns out that the Crank-Nicolson scheme is equivalent to the discretization

$$\frac{u^{n+1} - 2u^n + u^{n-1}}{\Delta t^2} = -\omega^2 \frac{1}{4} (u^{n+1} + 2u^n + u^{n-1}) = -\omega^2 u^n + \mathcal{O}(\Delta t^2). \tag{1.38}$$

That is, the Crank-Nicolson is equivalent to (1.7) for the second-order ODE, apart from an extra term of size t <sup>2</sup>, but this is an error of the same order as in the finite difference approximation on the left-hand side of the equation anyway. The fact that the Crank-Nicolson scheme is so close to (1.7) makes it a much better method than the Forward or Backward Euler methods for vibration problems, as will be illustrated in Sect. 1.5.4.

Deriving (1.38) is a bit tricky. We start with rewriting the Crank-Nicolson equations as follows

$$u^{n+1} - u^n = \frac{1}{2} \Delta t (v^{n+1} + v^n),\tag{1.39}$$

$$v''^{n+1} = v'' - \frac{1}{2} \Delta t \omega^2 (u^{n+1} + u^n),\tag{1.40}$$

and add the latter at the previous time level as well:

$$v'' = v''^{-1} - \frac{1}{2} \Delta t \alpha^2 (u'' + u^{n-1}) \,. \tag{1.41}$$

We can also rewrite (1.39) at the previous time level as

$$v^n + v^{n-1} = \frac{2}{\Delta t} (u^n - u^{n-1}) \,. \tag{1.42}$$

Inserting (1.40) for v<sup>n</sup>C<sup>1</sup> in (1.39) and (1.41) for v<sup>n</sup> in (1.39) yields after some reordering:

$$u^{n+1} - u^n = \frac{1}{2} \left( -\frac{1}{2} \Delta t \boldsymbol{\omega}^2 (\boldsymbol{u}^{n+1} + 2\boldsymbol{u}^n + \boldsymbol{u}^{n-1}) + \boldsymbol{v}^n + \boldsymbol{v}^{n-1} \right) \dots$$

Now, <sup>v</sup><sup>n</sup> <sup>C</sup> <sup>v</sup><sup>n</sup><sup>1</sup> can be eliminated by means of (1.42). The result becomes

$$u^{n+1} - 2u^n + u^{n-1} = -\Delta t^2 \omega^2 \frac{1}{4} (u^{n+1} + 2u^n + u^{n-1})\,. \tag{1.43}$$

It can be shown that

$$
\frac{1}{4}(\boldsymbol{\mu}^{n+1} + 2\boldsymbol{\mu}^{n} + \boldsymbol{\mu}^{n-1}) \approx \boldsymbol{\mu}^{n} + \mathcal{O}(\Delta t^{2}),
$$

meaning that (1.43) is an approximation to the centered scheme (1.7) for the secondorder ODE where the sampling error in the term t 2!2un is of the same order as the approximation errors in the finite differences, i.e., *<sup>O</sup>*.t 2/. The Crank-Nicolson scheme written as (1.43) therefore has consistent sampling of all terms at the same time point tn.

#### **1.5.4 Comparison of Schemes**

We can easily compare methods like the ones above (and many more!) with the aid of the Odespy<sup>13</sup> package. Below is a sketch of the code.

```
import odespy
import numpy as np
def f(u, t, w=1):
    # v, u numbering for EulerCromer to work well
    v, u = u # u is array of length 2 holding our [v, u]
    return [-w**2*u, v]
def run_solvers_and_plot(solvers, timesteps_per_period=20,
                         num_periods=1, I=1, w=2*np.pi):
    P = 2*np.pi/w # duration of one period
    dt = P/timesteps_per_period
    Nt = num_periods*timesteps_per_period
    T = Nt*dt
    t_mesh = np.linspace(0, T, Nt+1)
    legends = []
    for solver in solvers:
        solver.set(f_kwargs={'w': w})
        solver.set_initial_condition([0, I])
        u, t = solver.solve(t_mesh)
```
There is quite some more code dealing with plots also, and we refer to the source file vib\_undamped\_odespy.py for details. Observe that keyword arguments in f(u,t,w=1) can be supplied through a solver parameter f\_kwargs (dictionary of additional keyword arguments to f).

Specification of the Forward Euler, Backward Euler, and Crank-Nicolson schemes is done like this:

```
solvers = [
    odespy.ForwardEuler(f),
    # Implicit methods must use Newton solver to converge
    odespy.BackwardEuler(f, nonlinear_solver='Newton'),
    odespy.CrankNicolson(f, nonlinear_solver='Newton'),
    ]
```
The vib\_undamped\_odespy.py program makes two plots of the computed solutions with the various methods in the solvers list: one plot with u.t/ versus t, and one *phase plane plot* where v is plotted against u. That is, the phase plane plot is the curve .u.t/; v.t// parameterized by <sup>t</sup>. Analytically, <sup>u</sup> D <sup>I</sup> cos.!t/ and <sup>v</sup> <sup>D</sup> <sup>u</sup><sup>0</sup> D !I sin.!t/. The exact curve .u.t/; v.t// is therefore an ellipse, which often looks like a circle in a plot if the axes are automatically scaled. The important feature, however, is that the exact curve .u.t/; v.t// is closed and repeats itself for every period. Not all numerical schemes are capable of doing that, meaning that the amplitude instead shrinks or grows with time.

<sup>13</sup> https://github.com/hplgit/odespy

**Fig. 1.7** Comparison of classical schemes in the phase plane for two time step values

**Fig. 1.8** Comparison of solution curves for classical schemes

Figure 1.7 show the results. Note that Odespy applies the label MidpointImplicit for what we have specified as CrankNicolson in the code (CrankNicolson is just a synonym for class MidpointImplicit in the Odespy code). The Forward Euler scheme in Fig. 1.7 has a pronounced spiral curve, pointing to the fact that the amplitude steadily grows, which is also evident in Fig. 1.8. The Backward Euler scheme has a similar feature, except that the spriral goes inward and the amplitude is significantly damped. The changing amplitude and the spiral form decreases with decreasing time step. The Crank-Nicolson scheme looks much more accurate. In fact, these plots tell that the Forward and Backward Euler schemes are not suitable for solving our ODEs with oscillating solutions.

#### **1.5.5 Runge-Kutta Methods**

We may run two other popular standard methods for first-order ODEs, the 2nd- and 4th-order Runge-Kutta methods, to see how they perform. Figures 1.9 and 1.10 show the solutions with larger t values than what was used in the previous two plots.

**Fig. 1.9** Comparison of Runge-Kutta schemes in the phase plane

**Fig. 1.10** Comparison of Runge-Kutta schemes

The visual impression is that the 4th-order Runge-Kutta method is very accurate, under all circumstances in these tests, while the 2nd-order scheme suffers from amplitude errors unless the time step is very small.

The corresponding results for the Crank-Nicolson scheme are shown in Fig. 1.11. It is clear that the Crank-Nicolson scheme outperforms the 2nd-order Runge-Kutta method. Both schemes have the same order of accuracy *<sup>O</sup>*.t 2/, but their differences in the accuracy that matters in a real physical application is very clearly pronounced in this example. Exercise 1.13 invites you to investigate how the amplitude is computed by a series of famous methods for first-order ODEs.

#### **1.5.6 Analysis of the Forward Euler Scheme**

We may try to find exact solutions of the discrete equations (1.28)–(1.29) in the Forward Euler method to better understand why this otherwise useful method has so bad performance for vibration ODEs. An "ansatz" for the solution of the discrete equations is

$$\begin{aligned} u'' &= I A^n, \\ v'' &= q I A^n, \end{aligned}$$

**Fig. 1.11** Long-time behavior of the Crank-Nicolson scheme in the phase plane

where q and A are scalars to be determined. We could have used a complex exponential form ei!nt <sup>Q</sup> since we get oscillatory solutions, but the oscillations grow in the Forward Euler method, so the numerical frequency !Q will be complex anyway (producing an exponentially growing amplitude). Therefore, it is easier to just work with potentially complex A and q as introduced above.

The Forward Euler scheme leads to

$$\begin{aligned} A &= 1 + \Delta t q, \\ A &= 1 - \Delta t a^2 q^{-1} \ . \end{aligned}$$

We can easily eliminate <sup>A</sup>, get <sup>q</sup><sup>2</sup> <sup>C</sup> !<sup>2</sup> <sup>D</sup> <sup>0</sup>, and solve for

$$q = \pm i a\_{,}$$

which gives

 $A = 1 \pm \Delta ti a$  .

We shall take the real part of An as the solution. The two values of A are complex conjugates, and the real part of An will be the same for both roots. This is easy to realize if we rewrite the complex numbers in polar form, which is also convenient for further analysis and understanding. The polar form rei of a complex number <sup>x</sup> C iy has <sup>r</sup> D <sup>p</sup>x2 C y2 and - D tan1.y=x/. Hence, the polar form of the two values for A becomes

$$1 \pm \Delta tio = \sqrt{1 + \alpha^2 \Delta t^2} e^{\pm i \tan^{-1}(\alpha \Delta t)} \dots$$

Now it is very easy to compute An:

$$(\mathbf{l} \pm \Delta t i \omega)^n = (\mathbf{l} + \omega^2 \Delta t^2)^{n/2} e^{\pm ni \tan^{-1}(\omega \Delta t)}\dots$$

Since cos.n/ D cos.n/, the real parts of the two numbers become the same. We therefore continue with the solution that has the plus sign.

The general solution is <sup>u</sup><sup>n</sup> D CAn, where <sup>C</sup> is a constant determined from the initial condition: <sup>u</sup><sup>0</sup> D <sup>C</sup> D <sup>I</sup> . We have <sup>u</sup><sup>n</sup> D IAn and <sup>v</sup><sup>n</sup> D qIAn. The final solutions are just the real part of the expressions in polar form:

$$u^n = I(1 + \omega^2 \Delta t^2)^{n/2} \cos(n \tan^{-1}(\omega \Delta t)),\tag{1.44}$$

$$v^n = -\alpha I (1 + \alpha^2 \Delta t^2)^{n/2} \sin(n \tan^{-1}(\alpha \Delta t))\,. \tag{1.45}$$

The expression .1 C !2t 2/n=2 causes growth of the amplitude, since a number greater than one is raised to a positive exponent n=2. We can develop a series expression to better understand the formula for the amplitude. Introducing <sup>p</sup> D !t as the key variable and using sympy gives

```
>>> from sympy import *
>>> p = symbols('p', real=True)
>>> n = symbols('n', integer=True, positive=True)
>>> amplitude = (1 + p**2)**(n/2)
>>> amplitude.series(p, 0, 4)
1 + n*p**2/2 + O(p**4)
```
The amplitude goes like <sup>1</sup> C <sup>1</sup> <sup>2</sup>n!2t <sup>2</sup>, clearly growing linearly in time (with n). We can also investigate the error in the angular frequency by a series expansion:

>>> n\*atan(p).series(p, 0, 4) n\*(p - p\*\*3/3 + O(p\*\*4))

This means that the solution for u<sup>n</sup> can be written as

$$u^n = \left(1 + \frac{1}{2} n \omega^2 \Delta t^2 + \mathcal{O}(\Delta t^4)\right) \cos\left(\omega t - \frac{1}{3} \omega t \Delta t^2 + \mathcal{O}(\Delta t^4)\right) \dots$$

The error in the angular frequency is of the same order as in the scheme (1.7) for the second-order ODE, but the error in the amplitude is severe.

#### **1.6 Energy Considerations**

The observations of various methods in the previous section can be better interpreted if we compute a quantity reflecting the total *energy of the system*. It turns out that this quantity,

$$E(t) = \frac{1}{2} (u')^2 + \frac{1}{2} \rho^2 u^2,$$

is *constant* for all t. Checking that E.t/ really remains constant brings evidence that the numerical computations are sound. It turns out that E is proportional to the mechanical energy in the system. Conservation of energy is much used to check numerical simulations, so it is well invested time to dive into this subject.

#### **1.6.1 Derivation of the Energy Expression**

We start out with multiplying

$$u'' + a^2 u = 0,$$

by u<sup>0</sup> and integrating from 0 to T :

$$\int\_0^T u''u'dt + \int\_0^T \alpha^2 uu'dt = 0\dots$$

Observing that

$$
\mu'' u' = \frac{d}{dt} \frac{1}{2} (u')^2, \quad \mu u' = \frac{d}{dt} \frac{1}{2} u^2,
$$

we get

$$\int\_0^T \left( \frac{d}{dt} \frac{1}{2} (u')^2 + \frac{d}{dt} \frac{1}{2} \alpha^2 u^2 \right) dt = E(T) - E(0) = 0,$$

where we have introduced

$$E(t) = \frac{1}{2}(u')^2 + \frac{1}{2}\omega^2 u^2. \tag{1.46}$$

The important result from this derivation is that the total energy is constant:

$$E(t) = E(0).$$

## E.t/ **is closely related to the system's energy**

The quantity E.t/ derived above is physically not the mechanical energy of a vibrating mechanical system, but the energy per unit mass. To see this, we start with Newton's second law <sup>F</sup> D ma (<sup>F</sup> is the sum of forces, <sup>m</sup> is the mass of the system, and a is the acceleration). The displacement u is related to a through <sup>a</sup> D <sup>u</sup>00. With a spring force as the only force we have <sup>F</sup> D ku, where <sup>k</sup> is a spring constant measuring the stiffness of the spring. Newton's second law then implies the differential equation

$$-ku = mu''\quad\Rightarrow\quad m u'' + ku = 0\ .$$

This equation of motion can be turned into an energy balance equation by finding the work done by each term during a time interval Œ0; T . To this end, we multiply the equation by du D <sup>u</sup><sup>0</sup> dt and integrate:

$$\int\_0^T muu'dt + \int\_0^T kuu'dt = 0\dots$$

The result is

$$E(t) = E\_k(t) + E\_p(t) = 0,$$

where

$$E\_k(t) = \frac{1}{2}mv^2, \quad v = u',\tag{1.47}$$

is the *kinetic energy* of the system, and

$$E\_p(t) = \frac{1}{2}ku^2\tag{1.48}$$

is the *potential energy*. The sum E.t/ <sup>Q</sup> is the total mechanical energy. The derivation demonstrates the famous energy principle that, under the right physical circumstances, any change in the kinetic energy is due to a change in potential energy and vice versa. (This principle breaks down when we introduce damping in the system, as we do in Sect. 1.10.)

The equation mu00Cku <sup>D</sup> <sup>0</sup> can be divided by <sup>m</sup> and written as <sup>u</sup>00C!2u <sup>D</sup> <sup>0</sup> for ! D <sup>p</sup>k=m. The energy expression E.t/ D <sup>1</sup> <sup>2</sup> .u<sup>0</sup> /2 C <sup>1</sup> <sup>2</sup>!2u2 derived earlier is then E.t/=m <sup>Q</sup> , i.e., mechanical energy per unit mass.

**Energy of the exact solution** Analytically, we have u.t/ D <sup>I</sup> cos!t, if u.0/ D <sup>I</sup> and u<sup>0</sup> .0/ D <sup>0</sup>, so we can easily check the energy evolution and confirm that E.t/ is constant:

$$E(t) = \frac{1}{2}I^2(-\omega\sin\omega t)^2 + \frac{1}{2}\omega^2I^2\cos^2\omega t = \frac{1}{2}\omega^2(\sin^2\omega t + \cos^2\omega t) = \frac{1}{2}\omega^2\dots$$

**Growth of energy in the Forward Euler scheme** It is easy to show that the energy in the Forward Euler scheme increases when stepping from time level <sup>n</sup> to <sup>n</sup> C <sup>1</sup>.

$$\begin{aligned} E^{n+1} &= \frac{1}{2} (v^{n+1})^2 + \frac{1}{2} \alpha^2 (u^{n+1})^2 \\ &= \frac{1}{2} (v^n - \omega^2 \Delta t u^n)^2 + \frac{1}{2} \alpha^2 (u^n + \Delta t v^n)^2 \\ &= (1 + \Delta t^2 \omega^2) E^n .\end{aligned}$$

#### **1.6.2 An Error Measure Based on Energy**

The constant energy is well expressed by its initial value E.0/, so that the error in mechanical energy can be computed as a mesh function by

$$e\_E^n = \frac{1}{2} \left( \frac{u^{n+1} - u^{n-1}}{2\Delta t} \right)^2 + \frac{1}{2} \alpha^2 (u^n)^2 - E(0), \quad n = 1, \ldots, N\_l - 1,\tag{1.49}$$

where

$$E(0) = \frac{1}{2}V^2 + \frac{1}{2}\alpha^2I^2,$$

if u.0/ D <sup>I</sup> and <sup>u</sup><sup>0</sup> .0/ D <sup>V</sup> . Note that we have used a centered approximation to u0 : u<sup>0</sup> .tn/ ŒD2tu<sup>n</sup>.

A useful norm of the mesh function e<sup>n</sup> <sup>E</sup> for the discrete mechanical energy can be the maximum absolute value of e<sup>n</sup> E:

$$||e\_E^n||\_{\ell^\infty} = \max\_{1 \le n < N\_I} |e\_E^n|\ .$$

Alternatively, we can compute other norms involving integration over all mesh points, but we are often interested in worst case deviation of the energy, and then the maximum value is of particular relevance.

A vectorized Python implementation of e<sup>n</sup> <sup>E</sup> takes the form

```
# import numpy as np and compute u, t
dt = t[1]-t[0]
E = 0.5*((u[2:] - u[:-2])/(2*dt))**2 + 0.5*w**2*u[1:-1]**2
E0 = 0.5*V**2 + 0.5**w**2*I**2
e_E = E - E0
e_E_norm = np.abs(e_E).max()
```
The convergence rates of the quantity e\_E\_norm can be used for verification. The value of e\_E\_norm is also useful for comparing schemes through their ability to preserve energy. Below is a table demonstrating the relative error in total energy for various schemes (computed by the vib\_undamped\_odespy.py program). The test problem is <sup>u</sup><sup>00</sup> C 42u D <sup>0</sup> with u.0/ D <sup>1</sup> and <sup>u</sup><sup>0</sup> .0/ D <sup>0</sup>, so the period is 1 and E.t/ 4:93. We clearly see that the Crank-Nicolson and the Runge-Kutta schemes are superior to the Forward and Backward Euler schemes already after one period.


However, after 10 periods, the picture is much more dramatic:


The Runge-Kutta and Crank-Nicolson methods hardly change their energy error with T , while the error in the Forward Euler method grows to huge levels and a relative error of 1 in the Backward Euler method points to E.t/ ! <sup>0</sup> as <sup>t</sup> grows large.

Running multiple values of t, we can get some insight into the convergence of the energy error:


A striking fact from this table is that the error of the Forward Euler method is reduced by the same factor as t is reduced by, while the error in the Crank-Nicolson method has a reduction proportional to t <sup>2</sup> (we cannot say anything for the Backward Euler method). However, for the RK2 method, halving t reduces the error by almost a factor of 10 (!), and for the RK4 method the reduction seems proportional to t <sup>2</sup> only (and the trend is confirmed by running smaller time steps, so for t D 3:9 <sup>10</sup><sup>4</sup> the relative error of RK2 is a factor 10 smaller than that of RK4!).

#### **1.7 The Euler-Cromer Method**

While the Runge-Kutta methods and the Crank-Nicolson scheme work well for the vibration equation modeled as a first-order ODE system, both were inferior to the straightforward centered difference scheme for the second-order equation <sup>u</sup><sup>00</sup> C !2u D <sup>0</sup>. However, there is a similarly successful scheme available for the first-order system <sup>u</sup><sup>0</sup> D <sup>v</sup>, <sup>v</sup><sup>0</sup> D !2u, to be presented below. The ideas of the scheme and their further developments have become very popular in particle and rigid body dynamics and hence are widely used by physicists.

#### **1.7.1 Forward-Backward Discretization**

The idea is to apply a Forward Euler discretization to the first equation and a Backward Euler discretization to the second. In operator notation this is stated as

$$[D\_l^+u = v]^n,\tag{1.50}$$

$$\left[D\_{\iota}^{-}v = -\alpha^{2}u\right]^{n+1}.\tag{1.51}$$

We can write out the formulas and collect the unknowns on the left-hand side:

$$\boldsymbol{u}^{n+1} = \boldsymbol{u}^{n} + \Delta t \boldsymbol{v}^{n},\tag{1.52}$$

$$
v^{n+1} = v^n - \Delta t \alpha^2 u^{n+1}.\tag{1.53}$$

We realize that after u<sup>n</sup>C<sup>1</sup> has been computed from (1.52), it may be used directly in (1.53) to compute v<sup>n</sup>C<sup>1</sup>.

In physics, it is more common to update the v equation first, with a forward difference, and thereafter the u equation, with a backward difference that applies the most recently computed v value:

$$v^{n+1} = v^n - \Delta t a v^2 u^n,\tag{1.54}$$

$$u^{n+1} = u^n + \Delta t \, v^{n+1} \,. \tag{1.55}$$

The advantage of ordering the ODEs as in (1.54)–(1.55) becomes evident when considering complicated models. Such models are included if we write our vibration ODE more generally as

$$u'' + \mathbf{g}(u, u', t) = 0\ .$$

We can rewrite this second-order ODE as two first-order ODEs,

$$\begin{aligned} v' &= -\mathbf{g}(u, v, t), \\ u' &= v \,. \end{aligned}$$

This rewrite allows the following scheme to be used:

$$\begin{aligned} \upsilon^{n+1} &= \upsilon^n - \Delta t \,\bigotimes(\iota^n, \upsilon^n, t), \\\ u^{n+1} &= u^n + \Delta t \,\upsilon^{n+1}. \end{aligned}$$

We realize that the first update works well with any g since old values u<sup>n</sup> and v<sup>n</sup> are used. Switching the equations would demand u<sup>n</sup>C<sup>1</sup> and v<sup>n</sup>C<sup>1</sup> values in g and result in nonlinear algebraic equations to be solved at each time level.

The scheme (1.54)–(1.55) goes under several names: forward-backward scheme, semi-implicit Euler method14, semi-explicit Euler, symplectic Euler, Newton-Störmer-Verlet, and Euler-Cromer. We shall stick to the latter name.

How does the Euler-Cromer method preserve the total energy? We may run the example from Sect. 1.6.2:


The relative error in the total energy decreases as t <sup>2</sup>, and the error level is slightly lower than for the Crank-Nicolson and Runge-Kutta methods.

<sup>14</sup> http://en.wikipedia.org/wiki/Semi-implicit\_Euler\_method

#### **1.7.2 Equivalence with the Scheme for the Second-Order ODE**

We shall now show that the Euler-Cromer scheme for the system of first-order equations is equivalent to the centered finite difference method for the second-order vibration ODE (!).

We may eliminate the v<sup>n</sup> variable from (1.52)–(1.53) or (1.54)–(1.55). The v<sup>n</sup>C<sup>1</sup> term in (1.54) can be eliminated from (1.55):

$$u^{n+1} = u^n + \Delta t \left(v^n - \alpha^2 \Delta t u^n\right). \tag{1.56}$$

The v<sup>n</sup> quantity can be expressed by u<sup>n</sup> and u<sup>n</sup><sup>1</sup> using (1.55):

$$v'' = \frac{u'' - u^{n-1}}{\Delta t},$$

and when this is inserted in (1.56) we get

$$u^{n+1} = 2u^n - u^{n-1} - \Delta t^2 \alpha^2 u^n,\tag{1.57}$$

which is nothing but the centered scheme (1.7)! The two seemingly different numerical methods are mathematically equivalent. Consequently, the previous analysis of (1.7) also applies to the Euler-Cromer method. In particular, the amplitude is constant, given that the stability criterion is fulfilled, but there is always an angular frequency error (1.19). Exercise 1.18 gives guidance on how to derive the exact discrete solution of the two equations in the Euler-Cromer method.

Although the Euler-Cromer scheme and the method (1.7) are equivalent, there could be differences in the way they handle the initial conditions. Let us look into this topic. The initial condition <sup>u</sup><sup>0</sup> D <sup>0</sup> means <sup>u</sup><sup>0</sup> D <sup>v</sup> D <sup>0</sup>. From (1.54) we get

$$v^1 = v^0 - \Delta t a v^2 u^0 = \Delta t a v^2 u^0,$$

and from (1.55) it follows that

$$u^1 = u^0 + \Delta t \\ v^1 = u^0 - a^2 \Delta t^2 u^0 \dots$$

When we previously used a centered approximation of u<sup>0</sup> .0/ D <sup>0</sup> combined with the discretization (1.7) of the second-order ODE, we got a slightly different result: <sup>u</sup><sup>1</sup> D <sup>u</sup><sup>0</sup> <sup>1</sup> <sup>2</sup>!2t 2u0. The difference is <sup>1</sup> <sup>2</sup>!2t 2u0, which is of second order in t, seemingly consistent with the overall error in the scheme for the differential equation model.

A different view can also be taken. If we approximate u<sup>0</sup> .0/ D <sup>0</sup> by a backward difference, .u<sup>0</sup> <sup>u</sup>1/=t D <sup>0</sup>, we get <sup>u</sup><sup>1</sup> D <sup>u</sup><sup>0</sup>, and when combined with (1.7), it results in <sup>u</sup><sup>1</sup> D <sup>u</sup><sup>0</sup> !2t 2u0. This means that the Euler-Cromer method based on (1.55)–(1.54) corresponds to using only a first-order approximation to the initial condition in the method from Sect. 1.1.2.

Correspondingly, using the formulation (1.52)–(1.53) with <sup>v</sup><sup>n</sup> D <sup>0</sup> leads to <sup>u</sup><sup>1</sup> D <sup>u</sup><sup>0</sup>, which can be interpreted as using a forward difference approximation for the initial condition u<sup>0</sup> .0/ D <sup>0</sup>. Both Euler-Cromer formulations lead to slightly different values for u<sup>1</sup> compared to the method in Sect. 1.1.2. The error is <sup>1</sup> <sup>2</sup>!2t 2u0.

#### **1.7.3 Implementation**

**Solver function** The function below, found in vib\_undamped\_EulerCromer.py, implements the Euler-Cromer scheme (1.54)–(1.55):

```
import numpy as np
def solver(I, w, dt, T):
    """
    Solve v' = - w**2*u, u'=v for t in (0,T], u(0)=I and v(0)=0,
    by an Euler-Cromer method.
    """
    dt = float(dt)
    Nt = int(round(T/dt))
    u = np.zeros(Nt+1)
    v = np.zeros(Nt+1)
    t = np.linspace(0, Nt*dt, Nt+1)
    v[0] = 0
    u[0] = I
    for n in range(0, Nt):
        v[n+1] = v[n] - dt*w**2*u[n]
        u[n+1] = u[n] + dt*v[n+1]
    return u, v, t
```
**Verification** Since the Euler-Cromer scheme is equivalent to the finite difference method for the second-order ODE <sup>u</sup><sup>00</sup> C !2u D <sup>0</sup> (see Sect. 1.7.2), the performance of the above solver function is the same as for the solver function in Sect. 1.2. The only difference is the formula for the first time step, as discussed above. This deviation in the Euler-Cromer scheme means that the discrete solution listed in Sect. 1.4.4 is not a solution of the Euler-Cromer scheme!

To verify the implementation of the Euler-Cromer method we can adjust v[1] so that the computer-generated values can be compared with the formula (1.20) from in Sect. 1.4.4. This adjustment is done in an alternative solver function, solver\_ic\_fix in vib\_EulerCromer.py. Since we now have an exact solution of the discrete equations available, we can write a test function test\_solver for checking the equality of computed values with the formula (1.20):

```
def test_solver():
    """
    Test solver with fixed initial condition against
    equivalent scheme for the 2nd-order ODE u'' + u = 0.
    """
    I = 1.2; w = 2.0; T = 5
    dt = 2/w # longest possible time step
    u, v, t = solver_ic_fix(I, w, dt, T)
    from vib_undamped import solver as solver2 # 2nd-order ODE
    u2, t2 = solver2(I, w, dt, T)
    error = np.abs(u - u2).max()
    tol = 1E-14
    assert error < tol
```
Another function, demo, visualizes the difference between the Euler-Cromer scheme and the scheme (1.7) for the second-oder ODE, arising from the mismatch in the first time level.

**Using Odespy** The Euler-Cromer method is also available in the Odespy package. The important thing to remember, when using this implementation, is that we must order the unknowns as v and u, so the u vector at each time level consists of the velocity v as first component and the displacement u as second component:

```
# Define ODE
def f(u, t, w=1):
    v, u = u
    return [-w**2*u, v]
# Initialize solver
I = 1
w = 2*np.pi
import odespy
solver = odespy.EulerCromer(f, f_kwargs={'w': w})
solver.set_initial_condition([0, I])
# Compute time mesh
P = 2*np.pi/w # duration of one period
dt = P/timesteps_per_period
Nt = num_periods*timesteps_per_period
T = Nt*dt
import numpy as np
t_mesh = np.linspace(0, T, Nt+1)
# Solve ODE
u, t = solver.solve(t_mesh)
u = u[:,1] # Extract displacement
```
**Convergence rates** We may use the convergence\_rates function in the file vib\_undamped.pyto investigate the convergence rate of the Euler-Cromer method, see the convergence\_ratefunction in the file vib\_undamped\_EulerCromer.py. Since we could eliminate v to get a scheme for u that is equivalent to the finite difference method for the second-order equation in u, we would expect the convergence rates to be the same, i.e., <sup>r</sup> D <sup>2</sup>. However, measuring the convergence rate of <sup>u</sup> in the Euler-Cromer scheme shows that <sup>r</sup> D <sup>1</sup> only! Adjusting the initial condition does not change the rate. Adjusting !, as outlined in Sect. 1.4.2, gives a 4th-order method there, while there is no increase in the measured rate in the Euler-Cromer scheme. It is obvious that the Euler-Cromer scheme is dramatically much better than the two other first-order methods, Forward Euler and Backward Euler, but this is not reflected in the convergence rate of u.

#### **1.7.4 The Störmer-Verlet Algorithm**

Another very popular algorithm for vibration problems, especially for long time simulations, is the Störmer-Verlet algorithm. It has become *the* method among physicists for molecular simulations as well as particle and rigid body dynamics.

The method can be derived by applying the Euler-Cromer idea twice, in a symmetric fashion, during the interval Œtn; tnC1:


With mathematics,

$$\begin{aligned} \frac{v^{n+\frac{1}{2}} - v^n}{\frac{1}{2}\Delta t} &= -\omega^2 u^n, \\\frac{u^{n+\frac{1}{2}} - u^n}{\frac{1}{2}\Delta t} &= v^{n+\frac{1}{2}}, \\\frac{u^{n+1} - u^{n+\frac{1}{2}}}{\frac{1}{2}\Delta t} &= v^{n+\frac{1}{2}}, \\\ v^{n+1} - v^{n+\frac{1}{2}} &= -\omega^2 u^{n+1}. \end{aligned}$$

The two steps in the middle can be combined to

$$\frac{u^{n+1} - u^n}{\Delta t} = v^{n + \frac{1}{2}},$$

and consequently

$$
v^{n+\frac{1}{2}} = v^n - \frac{1}{2} \Delta t a^2 u^n,\tag{1.58}$$

$$
u^{n+1} = 
u^n + 
Δ t 
v^{n + 
\frac{1}{2}},\tag{1.59}$$

$$v^{n+1} = v^{n+\frac{1}{2}} - \frac{1}{2} \Delta t a^2 u^{n+1}.\tag{1.60}$$

Writing the last equation as <sup>v</sup><sup>n</sup> <sup>D</sup> <sup>v</sup><sup>n</sup> <sup>1</sup> <sup>2</sup> <sup>1</sup> <sup>2</sup>t!2un and using this v<sup>n</sup> in the first equation gives v<sup>n</sup><sup>C</sup> <sup>1</sup> <sup>2</sup> D <sup>v</sup><sup>n</sup> <sup>1</sup> <sup>2</sup> t!2un, and the scheme can be written as two steps:

$$v^{n + \frac{1}{2}} = v^{n - \frac{1}{2}} - \Delta t \boldsymbol{\omega}^2 \boldsymbol{u}^n,\tag{1.61}$$

$$
\mu^{n+1} = \mu^n + \Delta t \, v^{n + \frac{1}{2}},\tag{1.62}
$$

which is nothing but straightforward centered differences for the <sup>2</sup>-2 ODE system on a *staggered mesh*, see Sect. 1.8.1. We have thus seen that four different reasonings (discretizing <sup>u</sup><sup>00</sup> C !2u directly, using Euler-Cromer, using Stömer-Verlet, and using centered differences for the <sup>2</sup> - 2 system on a staggered mesh) all end up with the same equations! The main difference is that the traditional Euler-Cromer displays first-order convergence in t (due to less symmetry in the way u and v are treated) while the others are *<sup>O</sup>*.t 2/ schemes.

The most numerically stable scheme, with respect to accumulation of rounding errors, is (1.61)–(1.62). It has, according to [6], better properties in this regard than the direct scheme for the second-order ODE.

#### **1.8 Staggered Mesh**

A more intuitive discretization than the Euler-Cromer method, yet equivalent, employs solely centered differences in a natural way for the <sup>2</sup> - 2 first-order ODE system. The scheme is in fact fully equivalent to the second-order scheme for <sup>u</sup><sup>00</sup> C !u D <sup>0</sup>, also for the first time step. Such a scheme needs to operate on a *staggered mesh* in time. Staggered meshes are very popular in many physical application, maybe foremost fluid dynamics and electromagnetics, so the topic is important to learn.

#### **1.8.1 The Euler-Cromer Scheme on a Staggered Mesh**

In a staggered mesh, the unknowns are sought at different points in the mesh. Specifically, <sup>u</sup> is sought at integer time points tn and <sup>v</sup> is sought at tnC1=2 *between* two u points. The unknowns are then u1; v3=2; u2; v5=2, and so on. We typically use the notation u<sup>n</sup> and v<sup>n</sup><sup>C</sup> <sup>1</sup> <sup>2</sup> for the two unknown mesh functions. Figure 1.12 presents a graphical sketch of two mesh functions u and v on a staggered mesh.

On a staggered mesh it is natural to use centered difference approximations, expressed in operator notation as

$$[D\_l u = v]^{n + \frac{1}{2}},\tag{1.63}$$

$$[D\_l v = -\alpha^2 u]^{n+1},\tag{1.64}$$

or if we switch the sequence of the equations:

$$[D\_l v = -\omega^2 u]^n,\tag{1.65}$$

$$[D\_l u = v]^{n + \frac{1}{2}}\,. \tag{1.66}$$

Writing out the formulas gives

$$
\upsilon^{n+\frac{1}{2}} = \upsilon^{n-\frac{1}{2}} - \Delta t \upsilon^2 u^n,\tag{1.67}
$$

$$
\mu^{n+1} = \mu^n + \Delta t \, v^{n + \frac{1}{2}}.\tag{1.68}
$$

We can eliminate the v values and get back the centered scheme based on the second-order differential equation <sup>u</sup><sup>00</sup> C !2u D <sup>0</sup>, so all these three schemes are equivalent. However, they differ somewhat in the treatment of the initial conditions.

**Fig. 1.12** Examples on mesh functions on a staggered mesh in time

Suppose we have u.0/ D <sup>I</sup> and <sup>u</sup><sup>0</sup> .0/ D v.0/ D <sup>0</sup> as mathematical initial conditions. This means <sup>u</sup><sup>0</sup> D <sup>I</sup> and

$$v(0) \approx \frac{1}{2} \left( v^{-\frac{1}{2}} + v^{\frac{1}{2}} \right) = 0, \quad \Rightarrow \quad v^{-\frac{1}{2}} = -v^{\frac{1}{2}}.$$

Using the discretized equation (1.67) for <sup>n</sup> D <sup>0</sup> yields

$$v^{\frac{1}{2}} = v^{-\frac{1}{2}} - \Delta t w^2 I\_\*$$

and eliminating v <sup>1</sup> <sup>2</sup> D <sup>v</sup> <sup>1</sup> <sup>2</sup> results in

> v 1 2 D 1 2 t!<sup>2</sup> I;

and

$$u^1 = u^0 - \frac{1}{2} \Delta t^2 w^2 I,$$

which is exactly the same equation for u<sup>1</sup> as we had in the centered scheme based on the second-order differential equation (and hence corresponds to a centered difference approximation of the initial condition for u<sup>0</sup> .0/). The conclusion is that a staggered mesh is fully equivalent with that scheme, while the forward-backward version gives a slight deviation in the computation of u<sup>1</sup>.

We can redo the derivation of the initial conditions when u<sup>0</sup> .0/ D <sup>V</sup> :

$$v(0) \approx \frac{1}{2} \left( v^{-\frac{1}{2}} + v^{\frac{1}{2}} \right) = V, \quad \Rightarrow \quad v^{-\frac{1}{2}} = 2V - v^{\frac{1}{2}}.$$

Using this v <sup>1</sup> <sup>2</sup> in

$$v^{\frac{1}{2}} = v^{-\frac{1}{2}} - \Delta t a^2 I,$$

then gives v <sup>1</sup> <sup>2</sup> D <sup>V</sup> <sup>1</sup> <sup>2</sup>t!2I . The general initial conditions are therefore

$$u^0 = I,\tag{1.69}$$

$$
v^{\frac{1}{2}} = V - \frac{1}{2} \Delta t a^2 I\,.\tag{1.70}$$

#### **1.8.2 Implementation of the Scheme on a Staggered Mesh**

The algorithm goes like this:

	- (a) Compute u<sup>n</sup> from (1.68).
	- (b) Compute v<sup>n</sup><sup>C</sup> <sup>1</sup> <sup>2</sup> from (1.67).

**Implementation with integer indices** Translating the schemes (1.68) and (1.67) to computer code faces the problem of how to store and access v<sup>n</sup><sup>C</sup> <sup>1</sup> <sup>2</sup> , since arrays only allow integer indices with base 0. We must then introduce a convention: v<sup>1</sup><sup>C</sup> <sup>1</sup> 2 is stored in v[n] while v<sup>1</sup> <sup>1</sup> <sup>2</sup> is stored in v[n-1]. We can then write the algorithm in Python as

```
def solver(I, w, dt, T):
   dt = float(dt)
   Nt = int(round(T/dt))
   u = zeros(Nt+1)
   v = zeros(Nt+1)
   t = linspace(0, Nt*dt, Nt+1) # mesh for u
   t_v = t + dt/2 # mesh for v
   u[0] = I
   v[0] = 0 - 0.5*dt*w**2*u[0]
   for n in range(1, Nt+1):
       u[n] = u[n-1] + dt*v[n-1]
       v[n] = v[n-1] - dt*w**2*u[n]
   return u, t, v, t_v
```
Note that u and v are returned together with the mesh points such that the complete mesh function for u is described by u and t, while v and t\_v represent the mesh function for v.

**Implementation with half-integer indices** Some prefer to see a closer relationship between the code and the mathematics for the quantities with half-integer indices. For example, we would like to replace the updating equation for v[n] by

v[n+half] = v[n-half] - dt\*w\*\*2\*u[n]

This is easy to do if we could be sure that n+half means n and n-half means n-1. A possible solution is to define half as a special object such that an integer plus half results in the integer, while an integer minus half equals the integer minus 1. A simple Python class may realize the half object:

```
class HalfInt:
    def __radd__(self, other):
        return other
    def __rsub__(self, other):
        return other - 1
half = HalfInt()
```
The \_\_radd\_\_ function is invoked for all expressions n+half ("right add" with self as half and other as n). Similarly, the \_\_rsub\_\_ function is invoked for n-half and results in n-1.

Using the half object, we can implement the algorithms in an even more readable way:

```
def solver(I, w, dt, T):
   """
   Solve u'=v, v' = - w**2*u for t in (0,T], u(0)=I and v(0)=0,
   by a central finite difference method with time step dt on
   a staggered mesh with v as unknown at (i+1/2)*dt time points.
   """
   dt = float(dt)
   Nt = int(round(T/dt))
   u = zeros(Nt+1)
   v = zeros(Nt+1)
   t = linspace(0, Nt*dt, Nt+1) # mesh for u
   t_v = t + dt/2 # mesh for v
   u[0] = I
   v[0+half]=0- 0.5*dt*w**2*u[0]
   for n in range(1, Nt+1):
       u[n] = u[n-1] + dt*v[n-half]
       v[n+half] = v[n-half] - dt*w**2*u[n]
   return u, t, v[:-1], t_v[:-1]
```
Verification of this code is easy as we can just compare the computed u with the u produced by the solver function in vib\_undamped.py (which solves <sup>u</sup><sup>00</sup> C !2u D <sup>0</sup> directly). The values should coincide to machine precision since the two numerical methods are mathematically equivalent. We refer to the file vib\_undamped\_staggered.py for the details of a unit test (test\_staggered) that checks this property.

#### **1.9 Exercises and Problems**

**Problem 1.1: Use linear/quadratic functions for verification** Consider the ODE problem

$$u'' + \nu^2 u = f(t), \quad u(0) = I, \ u'(0) = V, \ t \in (0, T] \dots$$


```
import sympy as sym
V, t, I, w, dt = sym.symbols('V t I w dt') # global symbols
f = None # global variable for the source term in the ODE
def ode_source_term(u):
    """Return the terms in the ODE that the source term
    must balance, here u'' + w**2*u.
    u is symbolic Python function of t."""
    return sym.diff(u(t), t, t) + w**2*u(t)
def residual_discrete_eq(u):
    """Return the residual of the discrete eq. with u inserted."""
    R = ...
    return sym.simplify(R)
def residual_discrete_eq_step1(u):
    """Return the residual of the discrete eq. at the first
    step with u inserted."""
    R = ...
    return sym.simplify(R)
def DtDt(u, dt):
    """Return 2nd-order finite difference for u_tt.
    u is a symbolic Python function of t.
    """
    return ...
```

```
def main(u):
    """
    Given some chosen solution u (as a function of t, implemented
    as a Python function), use the method of manufactured solutions
    to compute the source term f, and check if u also solves
    the discrete equations.
    """
    print '=== Testing exact solution: %s ===' % u
    print "Initial conditions u(0)=%s, u'(0)=%s:" % \
          (u(t).subs(t, 0), sym.diff(u(t), t).subs(t, 0))
    # Method of manufactured solution requires fitting f
    global f # source term in the ODE
    f = sym.simplify(ode_lhs(u))
    # Residual in discrete equations (should be 0)
    print 'residual step1:', residual_discrete_eq_step1(u)
    print 'residual:', residual_discrete_eq(u)
def linear():
    main(lambda t: V*t + I)
if __name__ == '__main__':
    linear()
```
Fill in the various functions such that the calls in the main function works.


Filename: vib\_undamped\_verify\_mms.

#### **Exercise 1.2: Show linear growth of the phase with time**

Consider an exact solution <sup>I</sup> cos.!t/ and an approximation <sup>I</sup> cos.!t/ Q . Define the phase error as the time lag between the peak I in the exact solution and the corresponding peak in the approximation after m periods of oscillations. Show that this phase error is linear in m.

Filename: vib\_phase\_error\_growth.

#### **Exercise 1.3: Improve the accuracy by adjusting the frequency**

According to (1.19), the numerical frequency deviates from the exact frequency by a (dominating) amount !3t 2=24 > 0. Replace the w parameter in the algorithm in the solver function in vib\_undamped.py by w\*(1 - (1./24)\*w\*\*2\*dt\*\*2 and test how this adjustment in the numerical algorithm improves the accuracy (use t D 0:1 and simulate for 80 periods, with and without adjustment of !). Filename: vib\_adjust\_w.

#### **Exercise 1.4: See if adaptive methods improve the phase error**

Adaptive methods for solving ODEs aim at adjusting t such that the error is within a user-prescribed tolerance. Implement the equation <sup>u</sup><sup>00</sup> C <sup>u</sup> D <sup>0</sup> in the Odespy<sup>15</sup> software. Use the example from Section 3.2.11 in [9]. Run the scheme with a very low tolerance (say 10<sup>14</sup>) and for a long time, check the number of time points in the solver's mesh (len(solver.t\_all)), and compare the phase error with that produced by the simple finite difference method from Sect. 1.1.2 with the same number of (equally spaced) mesh points. The question is whether it pays off to use an adaptive solver or if equally many points with a simple method gives about the same accuracy.

Filename: vib\_undamped\_adaptive.

# **Exercise 1.5: Use a Taylor polynomial to compute** u1

As an alternative to computing u<sup>1</sup> by (1.8), one can use a Taylor polynomial with three terms:

$$
\mu(t\_1) \approx \mu(0) + \mu'(0)\Delta t + \frac{1}{2}\mu''(0)\Delta t^2 \dots
$$

With <sup>u</sup><sup>00</sup> D !2u and <sup>u</sup><sup>0</sup> .0/ D <sup>0</sup>, show that this method also leads to (1.8). Generalize the condition on u<sup>0</sup> .0/ to be u<sup>0</sup> .0/ D <sup>V</sup> and compute <sup>u</sup><sup>1</sup> in this case with both methods.

Filename: vib\_first\_step.

#### **Problem 1.6: Derive and investigate the velocity Verlet method**

The velocity Verlet method for <sup>u</sup><sup>00</sup> C !2u D <sup>0</sup> is based on the following ideas:


Derive the scheme, implement it, and determine empirically the convergence rate.

#### **Problem 1.7: Find the minimal resolution of an oscillatory function**

Sketch the function on a given mesh which has the highest possible frequency. That is, this oscillatory "cos-like" function has its maxima and minima at every two grid points. Find an expression for the frequency of this function, and use the result to find the largest relevant value of !t when ! is the frequency of an oscillating function and t is the mesh spacing.

Filename: vib\_largest\_wdt.

<sup>15</sup> https://github.com/hplgit/odespy

**Exercise 1.8: Visualize the accuracy of finite differences for a cosine function** We introduce the error fraction

$$E = \frac{[D\_l D\_l u]^n}{u''(t\_n)}$$

to measure the error in the finite difference approximation DtDtu to u00. Compute <sup>E</sup> for the specific choice of a cosine/sine function of the form <sup>u</sup> D exp .i!t/ and show that

$$E = \left(\frac{2}{a\Delta t}\right)^2 \sin^2\left(\frac{a\Delta t}{2}\right).$$

Plot <sup>E</sup> as a function of <sup>p</sup> D !t. The relevant values of <sup>p</sup> are Œ0; (see Exercise 1.7 for why p> does not make sense). The deviation of the curve from unity visualizes the error in the approximation. Also expand E as a Taylor polynomial in p up to fourth degree (use, e.g., sympy).

Filename: vib\_plot\_fd\_exp\_error.

#### **Exercise 1.9: Verify convergence rates of the error in energy**

We consider the ODE problem <sup>u</sup><sup>00</sup> C!2u D <sup>0</sup>, u.0/ D <sup>I</sup> , <sup>u</sup><sup>0</sup> .0/ D <sup>V</sup> , for <sup>t</sup> 2 .0; T . The total energy of the solution E.t/ D <sup>1</sup> <sup>2</sup> .u<sup>0</sup> /2 C <sup>1</sup> <sup>2</sup>!2u2 should stay constant. The error in energy can be computed as explained in Sect. 1.6.

Make a test function in a separate file, where code from vib\_undamped.py is imported, but the convergence\_rates and test\_convergence\_rates functions are copied and modified to also incorporate computations of the error in energy and the convergence rate of this error. The expected rate is 2, just as for the solution itself.

Filename: test\_error\_conv.

#### **Exercise 1.10: Use linear/quadratic functions for verification**

This exercise is a generalization of Problem 1.1 to the extended model problem (1.71) where the damping term is either linear or quadratic. Solve the various subproblems and see how the results and problem settings change with the generalized ODE in case of linear or quadratic damping. By modifying the code from Problem 1.1, sympy will do most of the work required to analyze the generalized problem.

Filename: vib\_verify\_mms.

#### **Exercise 1.11: Use an exact discrete solution for verification**

Write a test function in a separate file that employs the exact discrete solution (1.20) to verify the implementation of the solver function in the file vib\_undamped.py. Filename: test\_vib\_undamped\_exact\_discrete\_sol.

#### **Exercise 1.12: Use analytical solution for convergence rate tests**

The purpose of this exercise is to perform convergence tests of the problem (1.71) when s.u/ D cu, F .t/ D <sup>A</sup> sin t and there is no damping. Find the complete analytical solution to the problem in this case (most textbooks on mechanics or ordinary differential equations list the various elements you need to write down the exact solution, or you can use symbolic tools like sympy or wolframalpha.com).

**Fig. 1.13** The amplitude as it changes over 100 periods for RK3 and RK4

Modify the convergence\_rate function from the vib\_undamped.py program to perform experiments with the extended model. Verify that the error is of order t <sup>2</sup>. Filename: vib\_conv\_rate.

#### **Exercise 1.13: Investigate the amplitude errors of many solvers**

Use the program vib\_undamped\_odespy.py from Sect. 1.5.4 (utilize the function amplitudes) to investigate how well famous methods for 1st-order ODEs can preserve the amplitude of u in undamped oscillations. Test, for example, the 3rd- and 4th-order Runge-Kutta methods (RK3, RK4), the Crank-Nicolson method (CrankNicolson), the 2nd- and 3rd-order Adams-Bashforth methods (AdamsBashforth2, AdamsBashforth3), and a 2nd-order Backwards scheme (Backward2Step). The relevant governing equations are listed in the beginning of Sect. 1.5.

Running the code, we get the plots seen in Fig. 1.13, 1.14, and 1.15. They show that RK4 is superior to the others, but that also CrankNicolson performs well. In fact, with RK4 the amplitude changes by less than 0:1 per cent over the interval. Filename: vib\_amplitude\_errors.

#### **Problem 1.14: Minimize memory usage of a simple vibration solver**

We consider the model problem <sup>u</sup><sup>00</sup> C !2u D <sup>0</sup>, u.0/ D <sup>I</sup> , <sup>u</sup><sup>0</sup> .0/ D <sup>V</sup> , solved by a second-order finite difference scheme. A standard implementation typically employs an array u for storing all the u<sup>n</sup> values. However, at some time level n+1 where we want to compute u[n+1], all we need of previous u values are from level n and n-1. We can therefore avoid storing the entire array u, and instead work with u[n+1], u[n], and u[n-1], named as u, u\_n, u\_nmp1, for instance. Another possible naming convention is u, u\_n[0], u\_n[-1]. Store the solution in a file

**Fig. 1.14** The amplitude as it changes over 100 periods for Crank-Nicolson and Backward 2 step

**Fig. 1.15** The amplitude as it changes over 100 periods for Adams-Bashforth 2 and 3

for later visualization. Make a test function that verifies the implementation by comparing with the another code for the same problem. Filename: vib\_memsave0.

#### **Problem 1.15: Minimize memory usage of a general vibration solver**

The program vib.py stores the complete solution u0; u1;:::;uNt in memory, which is convenient for later plotting. Make a memory minimizing version of this program where only the last three u<sup>n</sup>C<sup>1</sup>, u<sup>n</sup>, and u<sup>n</sup><sup>1</sup> values are stored in memory under the names u, u\_n, and u\_nm1 (this is the naming convention used in this book). Write each computed .tnC1; u<sup>n</sup>C1/ pair to file. Visualize the data in the file (a cool solution is to read one line at a time and plot the u value using the lineby-line plotter in the visualize\_front\_ascii function - this technique makes it trivial to visualize very long time simulations).

Filename: vib\_memsave.

**Exercise 1.16: Implement the Euler-Cromer scheme for the generalized model** We consider the generalized model problem

$$
\mu u u'' + f(u') + s(u) = F(t), \quad u(0) = I, \ u'(0) = V \dots
$$


Filename: vib\_EulerCromer.

#### **Problem 1.17: Interpret** ŒDtDtu<sup>n</sup> **as a forward-backward difference**

Show that the difference ŒDtDtu<sup>n</sup> is equal to ŒD<sup>C</sup> <sup>t</sup> D <sup>t</sup> u<sup>n</sup> and D <sup>t</sup> D<sup>C</sup> <sup>t</sup> u<sup>n</sup>. That is, instead of applying a centered difference twice one can alternatively apply a mixture of forward and backward differences. Filename: vib\_DtDt\_fw\_bw.

#### **Exercise 1.18: Analysis of the Euler-Cromer scheme**

The Euler-Cromer scheme for the model problem <sup>u</sup>00C!2u D <sup>0</sup>, u.0/ D <sup>I</sup> , <sup>u</sup><sup>0</sup> .0/ D 0, is given in (1.55)–(1.54). Find the exact discrete solutions of this scheme and show that the solution for u<sup>n</sup> coincides with that found in Sect. 1.4.

*Hint* Use an "ansatz" <sup>u</sup><sup>n</sup> D <sup>I</sup> exp .i!t n/ Q and <sup>v</sup><sup>n</sup> D qu<sup>n</sup>, where !Q and <sup>q</sup> are unknown parameters. The following formula is handy:

$$e^{i\tilde{\omega}\Delta t} + e^{i\tilde{\phi}(-\Delta t)} - 2 = 2\left(\cosh(i\tilde{\omega}\Delta t) - 1\right) = -4\sin^2\left(\frac{\tilde{\omega}\Delta t}{2}\right) \dots$$

#### **1.10 Generalization: Damping, Nonlinearities, and Excitation**

We shall now generalize the simple model problem from Sect. 1.1 to include a possibly nonlinear damping term f .u<sup>0</sup> /, a possibly nonlinear spring (or restoring) force s.u/, and some external excitation F .t/:

$$mu'' + f(u') + s(u) = F(t), \quad u(0) = I, \; u'(0) = V, \; t \in (0, T]. \tag{1.71}$$

We have also included a possibly nonzero initial value for u<sup>0</sup> .0/. The parameters m, f .u0 /, s.u/, F .t/, I , V , and T are input data.

There are two main types of damping (friction) forces: linear f .u<sup>0</sup> / D bu, or quadratic f .u<sup>0</sup> / D bu<sup>0</sup> ju0 j. Spring systems often feature linear damping, while air resistance usually gives rise to quadratic damping. Spring forces are often linear: s.u/ D cu, but nonlinear versions are also common, the most famous is the gravity force on a pendulum that acts as a spring with s.u/ sin.u/.

#### **1.10.1 A Centered Scheme for Linear Damping**

Sampling (1.71) at a mesh point tn, replacing u00.tn/ by ŒDtDtu<sup>n</sup>, and u<sup>0</sup> .tn/ by ŒD2tu<sup>n</sup> results in the discretization

$$[mD\_lD\_l u + f(D\_{2l}u) + s(u) = F]^n,\tag{1.72}$$

which written out means

$$m\frac{u^{n+1} - 2u^n + u^{n-1}}{\Delta t^2} + f\left(\frac{u^{n+1} - u^{n-1}}{2\Delta t}\right) + s(u^n) = F'',\tag{1.73}$$

where <sup>F</sup> <sup>n</sup> as usual means F .t/ evaluated at <sup>t</sup> D tn. Solving (1.73) with respect to the unknown u<sup>n</sup>C<sup>1</sup> gives a problem: the u<sup>n</sup>C<sup>1</sup> inside the f function makes the equation *nonlinear* unless f .u<sup>0</sup> / is a linear function, f .u<sup>0</sup> / D bu<sup>0</sup> . For now we shall assume that f is linear in u<sup>0</sup> . Then

$$m\frac{u^{n+1} - 2u^n + u^{n-1}}{\Delta t^2} + b\frac{u^{n+1} - u^{n-1}}{2\Delta t} + s(u^n) = F^n,\tag{1.74}$$

which gives an explicit formula for u at each new time level:

$$u^{n+1} = \left(2mu^n + \left(\frac{b}{2}\Delta t - m\right)u^{n-1} + \Delta t^2(F^n - s(u^n))\right)\left(m + \frac{b}{2}\Delta t\right)^{-1}.\tag{1.75}$$

For the first time step we need to discretize u<sup>0</sup> .0/ D <sup>V</sup> as ŒD2tu D V <sup>0</sup> and combine with (1.75) for <sup>n</sup> D <sup>0</sup>. The discretized initial condition leads to

$$u^{-1} = u^1 - 2\Delta t V,\tag{1.76}$$

which inserted in (1.75) for <sup>n</sup> D <sup>0</sup> gives an equation that can be solved for <sup>u</sup><sup>1</sup>:

$$u^1 = u^0 + \Delta t \, V + \frac{\Delta t^2}{2m}(-bV - s(u^0) + F^0) \,. \tag{1.77}$$

#### **1.10.2 A Centered Scheme for Quadratic Damping**

When f .u<sup>0</sup> / D bu<sup>0</sup> ju0 j, we get a quadratic equation for <sup>u</sup><sup>n</sup>C<sup>1</sup> in (1.73). This equation can be straightforwardly solved by the well-known formula for the roots of a quadratic equation. However, we can also avoid the nonlinearity by introducing an approximation with an error of order no higher than what we already have from replacing derivatives with finite differences.

We start with (1.71) and only replace u<sup>00</sup> by DtDtu, resulting in

$$\left[mD\_lD\_l u + bu'|u'| + s(u) = F\right]^n. \tag{1.78}$$

Here, u<sup>0</sup> ju0 j is to be computed at time tn. The idea is now to introduce a *geometric mean*, defined by

$$(w^2)^n \approx w^{n - \frac{1}{2}} w^{n + \frac{1}{2}},$$

for some quantity w depending on time. The error in the geometric mean approximation is *<sup>O</sup>*.t 2/, the same as in the approximation <sup>u</sup><sup>00</sup> DtDtu. With <sup>w</sup> D <sup>u</sup><sup>0</sup> it follows that

$$[u'|u'|]^n \approx u'(t\_{n+\frac{1}{2}})|u'(t\_{n-\frac{1}{2}})|\dots$$

The next step is to approximate <sup>u</sup><sup>0</sup> at tn˙1=2, and fortunately a centered difference fits perfectly into the formulas since it involves u values at the mesh points only. With the approximations

$$u'(t\_{n+1/2}) \approx [D\_l u]^{n+\frac{1}{2}}, \quad u'(t\_{n-1/2}) \approx [D\_l u]^{n-\frac{1}{2}},\tag{1.79}$$

we get

$$\mathbb{E}\left[u'|u'\right]^n \approx [D\_l u]^{n+\frac{1}{2}} |[D\_l u]^{n-\frac{1}{2}}| = \frac{u^{n+1} - u^n}{\Delta t} \frac{|u^n - u^{n-1}|}{\Delta t}.\tag{1.80}$$

The counterpart to (1.73) is then

$$m\frac{u^{n+1} - 2u^n + u^{n-1}}{\Delta t^2} + b\frac{u^{n+1} - u^n}{\Delta t}\frac{|u^n - u^{n-1}|}{\Delta t} + s(u^n) = F^n,\qquad(1.81)$$

which is linear in the unknown u<sup>n</sup>C<sup>1</sup>. Therefore, we can easily solve (1.81) with respect to u<sup>n</sup>C<sup>1</sup> and achieve the explicit updating formula

$$\begin{aligned} u^{n+1} &= \left( m + b|u^n - u^{n-1}| \right)^{-1} \\ &\times \left( 2mu^n - mu^{n-1} + bu^n|u^n - u^{n-1}| + \Delta t^2 (F^n - s(u^n)) \right) . \end{aligned} \tag{1.82}$$

In the derivation of a special equation for the first time step we run into some trouble: inserting (1.76) in (1.82) for <sup>n</sup> D <sup>0</sup> results in a complicated nonlinear equation for u<sup>1</sup>. By thinking differently about the problem we can easily get away with the nonlinearity again. We have for <sup>n</sup> D <sup>0</sup> that bŒu<sup>0</sup> ju0 j 0 D bV j<sup>V</sup> j. Using this value in (1.78) gives

$$\left[mD\_lD\_l\mu + bV|V| + s(\mu) = F\right]^0. \tag{1.83}$$

Writing this equation out and using (1.76) results in the special equation for the first time step:

$$
\mu^1 = \mu^0 + \Delta t V + \frac{\Delta t^2}{2m} \left( -bV|V| - s(\mu^0) + F^0 \right). \tag{1.84}
$$

#### **1.10.3 A Forward-Backward Discretization of the Quadratic Damping Term**

The previous section first proposed to discretize the quadratic damping term ju<sup>0</sup> ju0 using centered differences: <sup>Œ</sup>jD2t <sup>j</sup>D2tu<sup>n</sup>. As this gives rise to a nonlinearity in u<sup>n</sup>C<sup>1</sup>, it was instead proposed to use a geometric mean combined with centered differences. But there are other alternatives. To get rid of the nonlinearity in <sup>Œ</sup>jD2t <sup>j</sup>D2tu<sup>n</sup>, one can think differently: apply a backward difference to <sup>j</sup>u<sup>0</sup> j, such that the term involves known values, and apply a forward difference to u<sup>0</sup> to make the term linear in the unknown u<sup>n</sup>C<sup>1</sup>. With mathematics,

$$\beta [\beta |u'|u']^n \approx \beta |[D\_l^- u]^n| [D\_l^+ u]^n = \beta \left| \frac{u^n - u^{n-1}}{\Delta t} \right| \frac{u^{n+1} - u^n}{\Delta t} \,. \tag{1.85}$$

The forward and backward differences both have an error proportional to t so one may think the discretization above leads to a first-order scheme. However, by looking at the formulas, we realize that the forward-backward differences in (1.85) result in exactly the same scheme as in (1.81) where we used a geometric mean and centered differences and committed errors of size *<sup>O</sup>*.t 2/. Therefore, the forward-backward differences in (1.85) act in a symmetric way and actually produce a second-order accurate discretization of the quadratic damping term.

#### **1.10.4 Implementation**

The algorithm arising from the methods in Sect.s 1.10.1 and 1.10.2 is very similar to the undamped case in Sect. 1.1.2. The difference is basically a question of different formulas for u<sup>1</sup> and u<sup>n</sup>C<sup>1</sup>. This is actually quite remarkable. The equation (1.71) is normally impossible to solve by pen and paper, but possible for some special choices of F , s, and f . On the contrary, the complexity of the nonlinear generalized model (1.71) versus the simple undamped model is not a big deal when we solve the problem numerically!

The computational algorithm takes the form


Modifying the solver function for the undamped case is fairly easy, the big difference being many more terms and if tests on the type of damping:

```
def solver(I, V, m, b, s, F, dt, T, damping='linear'):
    """
    Solve m*u'' + f(u') + s(u) = F(t) for t in (0,T],
    u(0)=I and u'(0)=V,
    by a central finite difference method with time step dt.
    If damping is 'linear', f(u')=b*u, while if damping is
    'quadratic', f(u')=b*u'*abs(u').
    F(t) and s(u) are Python functions.
    """
    dt = float(dt); b = float(b); m = float(m) # avoid integer div.
    Nt = int(round(T/dt))
    u = np.zeros(Nt+1)
    t = np.linspace(0, Nt*dt, Nt+1)
    u[0] = I
    if damping == 'linear':
        u[1] = u[0] + dt*V + dt**2/(2*m)*(-b*V - s(u[0]) + F(t[0]))
    elif damping == 'quadratic':
        u[1] = u[0] + dt*V + \
               dt**2/(2*m)*(-b*V*abs(V) - s(u[0]) + F(t[0]))
    for n in range(1, Nt):
        if damping == 'linear':
            u[n+1] = (2*m*u[n]+(b*dt/2 - m)*u[n-1] +
                      dt**2*(F(t[n]) - s(u[n])))/(m + b*dt/2)
        elif damping == 'quadratic':
            u[n+1] = (2*m*u[n] - m*u[n-1] + b*u[n]*abs(u[n] - u[n-1])
                      + dt**2*(F(t[n]) - s(u[n])))/\
                      (m + b*abs(u[n] - u[n-1]))
    return u, t
```
The complete code resides in the file vib.py.

#### **1.10.5 Verification**

**Constant solution** For debugging and initial verification, a constant solution is often very useful. We choose <sup>u</sup>e.t/ D <sup>I</sup> , which implies <sup>V</sup> D <sup>0</sup>. Inserted in the ODE, we get F .t/ D s.I / for any choice of <sup>f</sup> . Since the discrete derivative of a constant vanishes (in particular, ŒD2tI<sup>n</sup> <sup>D</sup> <sup>0</sup>, ŒDt I<sup>n</sup> <sup>D</sup> <sup>0</sup>, and ŒDtDt I<sup>n</sup> <sup>D</sup> 0), the constant solution also fulfills the discrete equations. The constant should therefore be reproduced to machine precision. The function test\_constant in vib.py implements this test.

**Linear solution** Now we choose a linear solution: <sup>u</sup><sup>e</sup> <sup>D</sup> ct <sup>C</sup> <sup>d</sup>. The initial condition u.0/ D <sup>I</sup> implies <sup>d</sup> D <sup>I</sup> , and <sup>u</sup><sup>0</sup> .0/ D <sup>V</sup> forces <sup>c</sup> to be <sup>V</sup> . Inserting <sup>u</sup><sup>e</sup> <sup>D</sup> V t <sup>C</sup> <sup>I</sup> in the ODE with linear damping results in

$$0 + bV + s(Vt + I) = F(t),$$

while quadratic damping requires the source term

$$0 + b|V|V + s(Vt + I) = F(t) \,.$$

Since the finite difference approximations used to compute u<sup>0</sup> all are exact for a linear function, it turns out that the linear ue is also a solution of the discrete equations. Exercise 1.10 asks you to carry out all the details.

**Quadratic solution** Choosing <sup>u</sup><sup>e</sup> <sup>D</sup> bt <sup>2</sup> <sup>C</sup> V t <sup>C</sup> <sup>I</sup> , with <sup>b</sup> arbitrary, fulfills the initial conditions and fits the ODE if F is adjusted properly. The solution also solves the discrete equations with linear damping. However, this quadratic polynomial in t does not fulfill the discrete equations in case of quadratic damping, because the geometric mean used in the approximation of this term introduces an error. Doing Exercise 1.10 will reveal the details. One can fit F <sup>n</sup> in the discrete equations such that the quadratic polynomial is reproduced by the numerical method (to machine precision).

**Catching bugs** How good are the constant and quadratic solutions at catching bugs in the implementation? Let us check that by introducing some bugs.


We realize that the constant solution is very useful for catching certain bugs because of its simplicity (easy to predict what the different terms in the formula should evaluate to), while the quadratic solution seems capable of detecting all (?) other kinds of typos in the scheme. These results demonstrate why we focus so much on exact, simple polynomial solutions of the numerical schemes in these writings.

#### **1.10.6 Visualization**

The functions for visualizations differ significantly from those in the undamped case in the vib\_undamped.py program because, in the present general case, we do not have an exact solution to include in the plots. Moreover, we have no good estimate of the periods of the oscillations as there will be one period determined by the system parameters, essentially the approximate frequency ps<sup>0</sup> .0/=m for linear s and small damping, and one period dictated by F .t/ in case the excitation is periodic. This is, however, nothing that the program can depend on or make use of. Therefore, the user has to specify T and the window width to get a plot that moves with the graph and shows the most recent parts of it in long time simulations.

The vib.py code contains several functions for analyzing the time series signal and for visualizing the solutions.

#### **1.10.7 User Interface**

The main function is changed substantially from the vib\_undamped.pycode, since we need to specify the new data c, s.u/, and F .t/. In addition, we must set T and the plot window width (instead of the number of periods we want to simulate as in vib\_undamped.py). To figure out whether we can use one plot for the whole time series or if we should follow the most recent part of u, we can use the plot\_empricial\_freq\_and\_amplitude function's estimate of the number of local maxima. This number is now returned from the function and used in main to decide on the visualization technique.

```
def main():
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument('--I', type=float, default=1.0)
    parser.add_argument('--V', type=float, default=0.0)
    parser.add_argument('--m', type=float, default=1.0)
    parser.add_argument('--c', type=float, default=0.0)
    parser.add_argument('--s', type=str, default='u')
    parser.add_argument('--F', type=str, default='0')
    parser.add_argument('--dt', type=float, default=0.05)
    parser.add_argument('--T', type=float, default=140)
    parser.add_argument('--damping', type=str, default='linear')
    parser.add_argument('--window_width', type=float, default=30)
    parser.add_argument('--savefig', action='store_true')
    a = parser.parse_args()
    from scitools.std import StringFunction
    s = StringFunction(a.s, independent_variable='u')
    F = StringFunction(a.F, independent_variable='t')
    I, V, m, c, dt, T, window_width, savefig, damping = \
       a.I, a.V, a.m, a.c, a.dt, a.T, a.window_width, a.savefig, \
       a.damping
    u, t = solver(I, V, m, c, s, F, dt, T)
    num_periods = empirical_freq_and_amplitude(u, t)
    if num_periods <= 15:
        figure()
        visualize(u, t)
    else:
        visualize_front(u, t, window_width, savefig)
    show()
```
The program vib.py contains the above code snippets and can solve the model problem (1.71). As a demo of vib.py, we consider the case <sup>I</sup> D <sup>1</sup>, <sup>V</sup> D <sup>0</sup>, <sup>m</sup> D <sup>1</sup>, <sup>c</sup> D 0:03, s.u/ D sin.u/, F .t/ D <sup>3</sup> cos.4t/, t D 0:05, and <sup>T</sup> D <sup>140</sup>. The relevant command to run is


**Fig. 1.16** Damped oscillator excited by a sinusoidal function

This results in a moving window following the function<sup>16</sup> on the screen. Figure 1.16 shows a part of the time series.

#### **1.10.8 The Euler-Cromer Scheme for the Generalized Model**

The ideas of the Euler-Cromer method from Sect. 1.7 carry over to the generalized model. We write (1.71) as two equations for <sup>u</sup> and <sup>v</sup> D <sup>u</sup><sup>0</sup> . The first equation is taken as the one with v<sup>0</sup> on the left-hand side:

$$v' = \frac{1}{m}(F(t) - s(u) - f(v)),\tag{1.86}$$

$$u' = v\,. \tag{1.87}$$

Again, the idea is to step (1.86) forward using a standard Forward Euler method, while we update u from (1.87) with a Backward Euler method, utilizing the recent, computed v<sup>n</sup>C<sup>1</sup> value. In detail,

$$\frac{\upsilon^{n+1} - \upsilon^n}{\Delta t} = \frac{1}{m} (F(t\_n) - s(\iota^n) - f(\upsilon^n)),\tag{1.88}$$

$$\frac{u^{n+1} - u^n}{\Delta t} = v^{n+1},\tag{1.89}$$

<sup>16</sup> http://tinyurl.com/hbcasmj/vib/html//mov-vib/vib\_generalized\_dt0.05/index.html

resulting in the explicit scheme

$$v''^{n+1} = v'' + \Delta t \frac{1}{m} (F(t\_n) - s(u'') - f(v'')),\tag{1.90}$$

$$u^{n+1} = u^n + \Delta t \, v^{n+1}.\tag{1.91}$$

We immediately note one very favorable feature of this scheme: all the nonlinearities in s.u/ and f .v/ are evaluated at a previous time level. This makes the Euler-Cromer method easier to apply and hence much more convenient than the centered scheme for the second-order ODE (1.71).

The initial conditions are trivially set as

$$v^0\_\ldots = V,\tag{1.92}$$

$$u^0 = I\,. \tag{1.93}$$

#### **1.10.9 The Störmer-Verlet Algorithm for the Generalized Model**

We can easily apply the ideas from Sect. 1.7.4 to extend that method to the generalized model

$$\begin{aligned} v' &= \frac{1}{m}(F(t) - s(u) - f(v)), \\ u' &= v \ . \end{aligned}$$

However, since the scheme is essentially centered differences for the ODE system on a staggered mesh, we do not go into detail here, but refer to Sect. 1.10.10.

#### **1.10.10 A Staggered Euler-Cromer Scheme for a Generalized Model**

The more general model for vibration problems,

$$mu'' + f(u') + s(u) = F(t), \quad u(0) = I, \ u'(0) = V, \ t \in (0, T], \qquad (1.94)$$

can be rewritten as a first-order ODE system

$$v' = m^{-1} \left( F(t) - f(v) - s(u) \right),\tag{1.95}$$

$$u' = v\,. \tag{1.96}$$

It is natural to introduce a staggered mesh (see Sect. 1.8.1) and seek u at mesh points tn (the numerical value is denoted by <sup>u</sup><sup>n</sup>) and <sup>v</sup> between mesh points at tnC1=2 (the numerical value is denoted by v<sup>n</sup><sup>C</sup> <sup>1</sup> <sup>2</sup> ). A centered difference approximation to (1.96)–(1.95) can then be written in operator notation as

$$[D\_l v = m^{-1} \left( F(t) - f(v) - s(u) \right)]^n,\tag{1.97}$$

$$[D\_l u = v]^{n + \frac{1}{2}}.\tag{1.98}$$

Written out,

$$\frac{v^{n+\frac{1}{2}} - v^{n-\frac{1}{2}}}{\Delta t} = m^{-1} \left( F^n - f(v^n) - s(u^n) \right),\tag{1.99}$$

$$\frac{u^n - u^{n-1}}{\Delta t} = v^{n + \frac{1}{2}} \,. \tag{1.100}$$

With linear damping, f .v/ <sup>D</sup> bv, we can use an arithmetic mean for f .vn/: f .vn/ D <sup>1</sup> <sup>2</sup> .f .v<sup>n</sup> <sup>1</sup> <sup>2</sup> / C f .v<sup>n</sup><sup>C</sup> <sup>1</sup> <sup>2</sup> //. The system (1.99)–(1.100) can then be solved with respect to the unknowns u<sup>n</sup> and v<sup>n</sup><sup>C</sup> <sup>1</sup> 2 :

$$v^{n+\frac{1}{2}} = \left(1 + \frac{b}{2m}\Delta t\right)^{-1} \left(v^{n-\frac{1}{2}} + \Delta t m^{-1} \left(F^n - \frac{1}{2}f(v^{n-\frac{1}{2}}) - s(u^n)\right)\right),\tag{1.101}$$

$$u^n = u^{n-1} + \Delta t v^{n-\frac{1}{2}}.\tag{1.102}$$

In case of quadratic damping, f .v/ D <sup>b</sup>jvjv, we can use a geometric mean: f .vn/ <sup>b</sup>jv<sup>n</sup> <sup>1</sup> <sup>2</sup> jv<sup>n</sup><sup>C</sup> <sup>1</sup> <sup>2</sup> . Inserting this approximation in (1.99)–(1.100) and solving for the unknowns u<sup>n</sup> and v<sup>n</sup><sup>C</sup> <sup>1</sup> <sup>2</sup> results in

$$v^{n+\frac{1}{2}} = \left(1 + \frac{b}{m} |v^{n-\frac{1}{2}}| \Delta t\right)^{-1} \left(v^{n-\frac{1}{2}} + \Delta t m^{-1} \left(F^n - s(u^n)\right)\right),\qquad(1.103)$$

$$
\mu^n = \mu^{n-1} + \Delta t v^{n-\frac{1}{2}}.\tag{1.104}
$$

The initial conditions are derived at the end of Sect. 1.8.1:

$$u^0 = I,\tag{1.105}$$

$$
v^{\frac{1}{2}} = V - \frac{1}{2} \Delta t a^2 I\,.\tag{1.106}$$

#### **1.10.11 The PEFRL 4th-Order Accurate Algorithm**

A variant of the Euler-Cromer type of algorithm, which provides an error *<sup>O</sup>*.t 4/ if f .v/ D <sup>0</sup>, is called PEFRL [14]. This algorithm is very well suited for integrating dynamic systems (especially those without damping) over very long time periods. Define

$$\mathbf{g}(u,v) = \frac{1}{m}(F(t) - s(u) - f(v))\,.$$

The algorithm is explicit and features these steps:

$$u^{n+1,1} = u^n + \xi \Delta t \, v^n,\tag{1.107}$$

$$v^{n+1,1} = v^n + \frac{1}{2}(1 - 2\lambda)\Delta t \lg(u^{n+1,1}, v^n),\tag{1.108}$$

$$u^{n+1,2} = u^{n+1,1} + \chi \Delta t v^{n+1,1},\tag{1.109}$$

$$\upsilon^{n+1,2} = \upsilon^{n+1,1} + \lambda \Delta t \,\mathrm{g}(\mu^{n+1,2}, \upsilon^{n+1,1}),\tag{1.110}$$

$$u^{n+1,3} = u^{n+1,2} + (1 - 2(\chi + \xi)) \Delta t v^{n+1,2},\tag{1.111}$$

$$v^{n+1,3} = v^{n+1,2} + \lambda \Delta t \,\mathrm{g}(u^{n+1,3}, v^{n+1,2}),\tag{1.112}$$

$$u^{n+1,4} = u^{n+1,3} + \underset{\ast}{\chi} \Delta t \, v^{n+1,3},\tag{1.113}$$

$$v^{n+1} = v^{n+1,3} + \frac{1}{2}(1 - 2\lambda)\Delta t \lg(u^{n+1,4}, v^{n+1,3}),\tag{1.114}$$

$$u^{n+1} = u^{n+1,4} + \xi \Delta t \, v^{n+1}.\tag{1.115}$$

The parameters , , and have the values

D 0:1786178958448091; (1.116)

$$
\lambda = -0.2123418310626054,\tag{1.117}
$$

$$
\chi = -0.06626458266981849 \,. \tag{1.118}
$$

#### **1.11 Exercises and Problems**

#### **Exercise 1.19: Implement the solver via classes**

Reimplement the vib.py program using a class Problem to hold all the physical parameters of the problem, a class Solver to hold the numerical parameters and compute the solution, and a class Visualizer to display the solution.

*Hint* Use the ideas and examples from Sections 5.5.1 and 5.5.2 in [9]. More specifically, make a superclass Problem for holding the scalar physical parameters of a problem and let subclasses implement the s.u/ and F .t/ functions as methods. Try to call up as much existing functionality in vib.py as possible. Filename: vib\_class.

#### **Problem 1.20: Use a backward difference for the damping term**

As an alternative to discretizing the damping terms ˇu<sup>0</sup> and <sup>ˇ</sup>ju<sup>0</sup> ju<sup>0</sup> by centered differences, we may apply backward differences:

$$\begin{aligned} [\boldsymbol{\mu}']^n &\approx [D\_l^- \boldsymbol{\mu}]^n, \\ [|\boldsymbol{\mu}'|\boldsymbol{\mu}']^n &\approx [|D\_l^- \boldsymbol{\mu}|D\_l^- \boldsymbol{\mu}]^n \\ &= |[D\_l^- \boldsymbol{\mu}]^n|[D\_l^- \boldsymbol{\mu}]^n .\end{aligned}$$

The advantage of the backward difference is that the damping term is evaluated using known values u<sup>n</sup> and u<sup>n</sup><sup>1</sup> only. Extend the vib.py code with a scheme based on using backward differences in the damping terms. Add statements to compare the original approach with centered difference and the new idea launched in this exercise. Perform numerical experiments to investigate how much accuracy that is lost by using the backward differences.

Filename: vib\_gen\_bwdamping.

#### **Exercise 1.21: Use the forward-backward scheme with quadratic damping**

We consider the generalized model with quadratic damping, expressed as a system of two first-order equations as in Sect. 1.10.10:

$$\begin{aligned} u' &= v, \\ v' &= \frac{1}{m} \left( F(t) - \beta |v| v - s(u) \right) \dots \end{aligned}$$

However, contrary to what is done in Sect. 1.10.10, we want to apply the idea of a forward-backward discretization: u is marched forward by a one-sided Forward Euler scheme applied to the first equation, and thereafter v can be marched forward by a Backward Euler scheme in the second equation, see in Sect. 1.7. Express the idea in operator notation and write out the scheme. Unfortunately, the backward difference for the <sup>v</sup> equation creates a nonlinearity jv<sup>n</sup>C<sup>1</sup>jv<sup>n</sup>C<sup>1</sup>. To linearize this nonlinearity, use the known value v<sup>n</sup> inside the absolute value factor, i.e., jv<sup>n</sup>C<sup>1</sup>jv<sup>n</sup>C<sup>1</sup> jv<sup>n</sup>jv<sup>n</sup>C<sup>1</sup>. Show that the resulting scheme is equivalent to the one in Sect. 1.10.10 for some time level <sup>n</sup> <sup>1</sup>.

What we learn from this exercise is that the first-order differences and the linearization trick play together in "the right way" such that the scheme is as good as when we (in Sect. 1.10.10) carefully apply centered differences and a geometric mean on a staggered mesh to achieve second-order accuracy. There is a difference in the handling of the initial conditions, though, as explained at the end of Sect. 1.7. Filename: vib\_gen\_bwdamping.

#### **1.12 Applications of Vibration Models**

The following text derives some of the most well-known physical problems that lead to second-order ODE models of the type addressed in this book. We consider a simple spring-mass system; thereafter extended with nonlinear spring, damping, and external excitation; a spring-mass system with sliding friction; a simple and a physical (classical) pendulum; and an elastic pendulum.

#### **1.12.1 Oscillating Mass Attached to a Spring**

The most fundamental mechanical vibration system is depicted in Fig. 1.17. A body with mass m is attached to a spring and can move horizontally without friction (in the wheels). The position of the body is given by the vector r.t/ D u.t/i, where i is a unit vector in x direction. There is only one force acting on the body: a spring force <sup>F</sup> <sup>s</sup> D kui, where <sup>k</sup> is a constant. The point <sup>x</sup> <sup>D</sup> <sup>0</sup>, where <sup>u</sup> <sup>D</sup> <sup>0</sup>, must

**Fig. 1.17** Simple oscillating mass

therefore correspond to the body's position where the spring is neither extended nor compressed, so the force vanishes.

The basic physical principle that governs the motion of the body is Newton's second law of motion: F D <sup>m</sup>a, where F is the sum of forces on the body, <sup>m</sup> is its mass, and a D Rr is the acceleration. We use the dot for differentiation with respect to time, which is usual in mechanics. Newton's second law simplifies here to <sup>F</sup> <sup>s</sup> <sup>D</sup> muRi, which translates to

$$-ku = m\ddot{u}\,.$$

Two initial conditions are needed: u.0/ D <sup>I</sup> , u.0/ P D <sup>V</sup> . The ODE problem is normally written as

$$m\ddot{u} + ku = 0, \quad u(0) = I, \ \dot{u}(0) = V \,. \tag{1.119}$$

It is not uncommon to divide by <sup>m</sup> and introduce the frequency ! <sup>D</sup> <sup>p</sup>k=m:

$$
\ddot{u} + a^2 u = 0, \quad u(0) = I, \ \dot{u}(0) = V. \tag{1.120}
$$

This is the model problem in the first part of this chapter, with the small difference that we write the time derivative of u with a dot above, while we used u<sup>0</sup> and u<sup>00</sup> in previous parts of the book.

Since only one scalar mathematical quantity, u.t/, describes the complete motion, we say that the mechanical system has one degree of freedom (DOF).

**Scaling** For numerical simulations it is very convenient to scale (1.120) and thereby get rid of the problem of finding relevant values for all the parameters m, k, I , and V . Since the amplitude of the oscillations are dictated by I and V (or more precisely, V =!), we scale <sup>u</sup> by <sup>I</sup> (or V =! if <sup>I</sup> D <sup>0</sup>):

$$
\bar{u} = \frac{u}{I}, \quad \bar{t} = \frac{t}{t\_c}.
$$

The time scale tc is normally chosen as the inverse period 2=! or angular frequency 1=!, most often as tc <sup>D</sup> 1=!. Inserting the dimensionless quantities <sup>u</sup><sup>N</sup> and

**Fig. 1.18** General oscillating system

t <sup>N</sup> in (1.120) results in the scaled problem

$$\frac{d^2\bar{u}}{d\bar{t}^2} + \bar{u} = 0, \quad \bar{u}(0) = 1, \\ \frac{\bar{u}}{\bar{t}}(0) = \beta = \frac{V}{I\omega},$$

where <sup>ˇ</sup> is a dimensionless number. Any motion that starts from rest (<sup>V</sup> D <sup>0</sup>) is free of parameters in the scaled model!

**The physics** The typical physics of the system in Fig. 1.17 can be described as follows. Initially, we displace the body to some position <sup>I</sup> , say at rest (<sup>V</sup> D <sup>0</sup>). After releasing the body, the spring, which is extended, will act with a force kI i and pull the body to the left. This force causes an acceleration and therefore increases velocity. The body passes the point <sup>x</sup> D <sup>0</sup>, where <sup>u</sup> D <sup>0</sup>, and the spring will then be compressed and act with a force kxi against the motion and cause retardation. At some point, the motion stops and the velocity is zero, before the spring force kxi has worked long enough to push the body in positive direction. The result is that the body accelerates back and forth. As long as there is no friction forces to damp the motion, the oscillations will continue forever.

#### **1.12.2 General Mechanical Vibrating System**

The mechanical system in Fig. 1.17 can easily be extended to the more general system in Fig. 1.18, where the body is attached to a spring and a dashpot, and also subject to an environmental force F .t/i. The system has still only one degree of freedom since the body can only move back and forth parallel to the x axis. The spring force was linear, <sup>F</sup> <sup>s</sup> D kui, in Sect. 1.12.1, but in more general cases it can depend nonlinearly on the position. We therefore set <sup>F</sup> <sup>s</sup> <sup>D</sup> s.u/i. The dashpot, which acts as a damper, results in a force <sup>F</sup> <sup>d</sup> that depends on the body's velocity <sup>u</sup>P and that always acts against the motion. The mathematical model of the force is written <sup>F</sup> <sup>d</sup> <sup>D</sup> f .u/<sup>P</sup> <sup>i</sup>. A positive <sup>u</sup><sup>P</sup> must result in a force acting in the positive <sup>x</sup> direction. Finally, we have the external environmental force <sup>F</sup> <sup>e</sup> <sup>D</sup> F .t/i.

Newton's second law of motion now involves three forces:

$$F(t)\dot{\mathbf{r}} - f(\dot{u})\dot{\mathbf{r}} - s(u)\dot{\mathbf{r}} = m\ddot{u}\dot{\mathbf{r}}\text{ .}$$

The common mathematical form of the ODE problem is

$$m\ddot{u} + f(\dot{u}) + s(u) = F(t), \quad u(0) = I, \ \dot{u}(0) = V. \tag{1.121}$$

This is the generalized problem treated in the last part of the present chapter, but with prime denoting the derivative instead of the dot.

The most common models for the spring and dashpot are linear: f .u/P D buP with a constant <sup>b</sup> <sup>0</sup>, and s.u/ D ku for a constant <sup>k</sup>.

**Scaling** A specific scaling requires specific choices of f , s, and F . Suppose we have

$$f(\dot{u}) = b|\dot{u}|\dot{u}, \quad s(u) = ku, \quad F(t) = A\sin(\phi t).$$

We introduce dimensionless variables as usual, <sup>u</sup><sup>N</sup> <sup>D</sup> u=uc and <sup>t</sup> <sup>N</sup> <sup>D</sup> t=tc . The scale uc depends both on the initial conditions and F , but as time grows, the effect of the initial conditions die out and <sup>F</sup> will drive the motion. Inserting <sup>u</sup>N and <sup>t</sup> <sup>N</sup> in the ODE gives

$$m\frac{u\_c}{t\_c^2}\frac{d^2\bar{u}}{d\bar{t}^2} + b\frac{u\_c^2}{t\_c^2}\left|\frac{d\bar{u}}{d\bar{t}}\right|\frac{d\bar{u}}{d\bar{t}} + ku\_c\bar{u} = A\sin(\phi t\_c\bar{t})\,.$$

We divide by uc=t <sup>2</sup> <sup>c</sup> and demand the coefficients of the <sup>u</sup><sup>N</sup> and the forcing term from F .t/ to have unit coefficients. This leads to the scales

$$t\_c = \sqrt{\frac{m}{k}}, \quad u\_c = \frac{A}{k}.$$

The scaled ODE becomes

$$\frac{d^2\bar{u}}{d\bar{t}^2} + 2\beta \left| \frac{d\bar{u}}{d\bar{t}} \right| \frac{d\bar{u}}{d\bar{t}} + \bar{u} = \sin(\gamma\bar{t}),\tag{1.122}$$

where there are two dimensionless numbers:

$$
\beta = \frac{Ab}{2mk}, \quad \gamma = \phi \sqrt{\frac{m}{k}} \dots
$$

The ˇ number measures the size of the damping term (relative to unity) and is assumed to be small, basically because b is small. The number is the ratio of the time scale of free vibrations and the time scale of the forcing. The scaled initial conditions have two other dimensionless numbers as values:

$$
\bar{u}(0) = \frac{Ik}{A}, \quad \frac{d\bar{u}}{d\bar{t}} = \frac{t\_c}{u\_c}V = \frac{V}{A}\sqrt{mk} \dots
$$

#### **1.12.3 A Sliding Mass Attached to a Spring**

Consider a variant of the oscillating body in Sect. 1.12.1 and Fig. 1.17: the body rests on a flat surface, and there is sliding friction between the body and the surface. Figure 1.19 depicts the problem.

**Fig. 1.19** Sketch of a body sliding on a surface

The body is attached to a spring with spring force s.u/i. The friction force is proportional to the normal force on the surface, mgj , and given by f .u/P i, where

$$f(\dot{u}) = \begin{cases} -\mu mg, & \dot{u} < 0, \\ \mu mg, & \dot{u} > 0, \\ 0, & \dot{u} = 0 \end{cases}$$

:

Here, is a friction coefficient. With the signum function

$$\text{sign}(\mathbf{x}) = \begin{cases} -1, & \mathbf{x} < \mathbf{0}, \\ 1, & \mathbf{x} > \mathbf{0}, \\ 0, & \mathbf{x} = \mathbf{0} \end{cases}$$

we can simply write f .u/P D mg sign.u/P (the sign function is implemented by numpy.sign).

The equation of motion becomes

$$
\mu \ddot{u} + \mu \text{mgsign}(\dot{u}) + s(u) = 0, \quad u(0) = I, \ \dot{u}(0) = V. \tag{1.123}
$$

#### **1.12.4 A Jumping Washing Machine**

A washing machine is placed on four springs with efficient dampers. If the machine contains just a few clothes, the circular motion of the machine induces a sinusoidal external force from the floor and the machine will jump up and down if the frequency of the external force is close to the natural frequency of the machine and its spring-damper system.

#### **1.12.5 Motion of a Pendulum**

**Simple pendulum** A classical problem in mechanics is the motion of a pendulum. We first consider a simplified pendulum<sup>17</sup> (sometimes also called a mathematical

<sup>17</sup> https://en.wikipedia.org/wiki/Pendulum

**Fig. 1.20** Sketch of a simple pendulum

pendulum): a small body of mass m is attached to a massless wire and can oscillate back and forth in the gravity field. Figure 1.20 shows a sketch of the problem.

The motion is governed by Newton's 2nd law, so we need to find expressions for the forces and the acceleration. Three forces on the body are considered: an unknown force S from the wire, the gravity force mg, and an air resistance force, 1 <sup>2</sup>CD%Ajvjv, hereafter called the drag force, directed against the velocity of the body. Here, CD is a drag coefficient, % is the density of air, A is the cross section area of the body, and v is the magnitude of the velocity.

We introduce a coordinate system with polar coordinates and unit vectors <sup>i</sup> <sup>r</sup> and i as shown in Fig. 1.21. The position of the center of mass of the body is

$$r(t) = x\_0 \dot{\mathbf{r}} + y\_0 \dot{\mathbf{J}} + L\dot{\mathbf{r}}\_r,$$

where i and j are unit vectors in the corresponding Cartesian coordinate system in the <sup>x</sup> and <sup>y</sup> directions, respectively. We have that <sup>i</sup> <sup>r</sup> <sup>D</sup> cos i C sin j .

The forces are now expressed as follows.


Since a positive velocity means movement in the direction of i - , the drag force must be directed along i so it works against the motion. We assume motion in air so that the added mass effect can be neglected (for a spherical body, the added mass is <sup>1</sup> <sup>2</sup>%V , where V is the volume of the body). Also the buoyancy effect can be neglected for motion in the air when the density difference between the fluid and the body is so significant.

The velocity of the body is found from r:

$$\mathbf{v}(t) = \dot{\mathbf{r}}(t) = \frac{d}{d\theta}(\mathbf{x}\_0 \dot{\mathbf{r}} + \mathbf{y}\_0 \dot{\mathbf{y}} + L\dot{\mathbf{r}}\_r)\frac{d\theta}{dt} = L\dot{\theta}\dot{\mathbf{r}}\_\theta \dot{\mathbf{r}}\_r$$

since <sup>d</sup> d i <sup>r</sup> D i - . It follows that <sup>v</sup> D jvj D <sup>L</sup> <sup>P</sup> -. The acceleration is

$$a(t) = \dot{\nu}(r) = \frac{d}{dt}(L\dot{\theta}\dot{\mathbf{i}}\_{\theta}) = L\ddot{\theta}\dot{\mathbf{i}}\_{\theta} + L\dot{\theta}\frac{d\dot{\mathbf{i}}\_{\theta}}{d\theta}\dot{\theta} = L\ddot{\theta}\dot{\mathbf{i}}\_{\theta} - L\dot{\theta}^2\dot{\mathbf{i}}\_{r},$$

since <sup>d</sup> d i -D <sup>i</sup> <sup>r</sup> .

Newton's 2nd law of motion becomes

$$-S\dot{\mathbf{r}}\_r + mg(-\sin\theta \,\dot{\mathbf{r}}\_\theta + \cos\theta \,\dot{\mathbf{r}}\_r) - \frac{1}{2}C\_D\varrho AL^2|\dot{\theta}|\dot{\theta} \,\dot{\mathbf{r}}\_\theta = mL\ddot{\theta}\dot{\theta} \,\dot{\mathbf{r}}\_\theta - L\dot{\theta}^2 \mathbf{\dot{r}}\_r,$$

leading to two component equations

$$-S + mg\cos\theta = -L\dot{\theta}^2,\tag{1.124}$$

$$-mg\sin\theta - \frac{1}{2}C\_D\varrho AL^2|\dot{\theta}|\dot{\theta} = mL\ddot{\theta}\ . \tag{1.125}$$

From (1.124) we get an expression for <sup>S</sup> D mg cos - C <sup>L</sup> <sup>P</sup> - <sup>2</sup>, and from (1.125) we get a differential equation for the angle -.t/. This latter equation is ordered as

$$m\ddot{\theta} + \frac{1}{2}C\_D \varrho AL|\dot{\theta}|\dot{\theta} + \frac{mg}{L}\sin\theta = 0\,\,. \tag{1.126}$$

Two initial conditions are needed: - D  and <sup>P</sup> - D ˝. Normally, the pendulum motion is started from rest, which means ˝ D <sup>0</sup>.

Equation (1.126) fits the general model used in (1.71) in Sect. 1.10 if we define <sup>u</sup> D -, f .u<sup>0</sup> / D <sup>1</sup> <sup>2</sup>CD%ALj Puj Pu, s.u/ D <sup>L</sup>1mg sin <sup>u</sup>, and <sup>F</sup> D <sup>0</sup>. If the body is a sphere with radius <sup>R</sup>, we can take CD <sup>D</sup> 0:4 and <sup>A</sup> <sup>D</sup> R<sup>2</sup>. Exercise 1.25 asks you to scale the equations and carry out specific simulations with this model.

**Physical pendulum** The motion of a compound or physical pendulum where the wire is a rod with mass, can be modeled very similarly. The governing equation is <sup>I</sup>a D T where <sup>I</sup> is the moment of inertia of the entire body about the point .x0; y0/, and T is the sum of moments of the forces with respect to .x0; y0/. The vector equation reads

$$\begin{split} & \left( -S\dot{\mathbf{i}}\_r + m\mathbf{g} \left( -\sin\theta \dot{\mathbf{i}}\_\theta + \cos\theta \dot{\mathbf{i}}\_r \right) - \frac{1}{2} C\_D \varrho A L^2 |\dot{\theta}| \dot{\theta} \dot{\mathbf{i}}\_\theta \right) \\ &= I \left( L \ddot{\theta} \dot{\theta} \dot{\mathbf{i}}\_\theta - L \dot{\theta}^2 \dot{\mathbf{i}}\_r \right). \end{split}$$

The component equation in i direction gives the equation of motion for -.t/:

$$I\ddot{\theta} + \frac{1}{2}C\_D \mathcal{Q} A L^3 |\dot{\theta}| \dot{\theta} + mgL \sin \theta = 0 \,. \tag{1.127}$$

#### **1.12.6 Dynamic Free Body Diagram During Pendulum Motion**

Usually one plots the mathematical quantities as functions of time to visualize the solution of ODE models. Exercise 1.25 asks you to do this for the motion of a pendulum in the previous section. However, sometimes it is more instructive to look at other types of visualizations. For example, we have the pendulum and the free body diagram in Fig. 1.20 and 1.21. We may think of these figures as animations in time instead. Especially the free body diagram will show both the motion of the pendulum *and* the size of the forces during the motion. The present section exemplifies how to make such a dynamic body diagram. Two typical snapshots of free body diagrams are displayed below (the drag force is magnified 5 times to become more visual!).

Dynamic physical sketches, coupled to the numerical solution of differential equations, requires a program to produce a sketch for the situation at each time level. Pysketcher<sup>18</sup> is such a tool. In fact (and not surprising!) Fig. 1.20 and 1.21 were drawn using Pysketcher. The details of the drawings are explained in the Pysketcher tutorial19. Here, we outline how this type of sketch can be used to create an animated free body diagram during the motion of a pendulum.

Pysketcher is actually a layer of useful abstractions on top of standard plotting packages. This means that we in fact apply Matplotlib to make the animated free body diagram, but instead of dealing with a wealth of detailed Matplotlib commands, we can express the drawing in terms of more high-level objects, e.g., objects for the wire, angle -, body with mass m, arrows for forces, etc. When the position of these objects are given through variables, we can just couple those variables to the dynamic solution of our ODE and thereby make a unique drawing for each - value in a simulation.

**Writing the solver** Let us start with the most familiar part of the current problem: writing the solver function. We use Odespy for this purpose. We also work with dimensionless equations. Since can be viewed as dimensionless, we only need to introduce a dimensionless time, here taken as t <sup>N</sup> D t=pL=g. The resulting dimensionless mathematical model for -, the dimensionless angular velocity !, the dimensionless wire force <sup>S</sup>N, and the dimensionless drag force <sup>D</sup><sup>N</sup> is then

$$\frac{d\boldsymbol{\omega}}{d\tilde{t}} = -\boldsymbol{\alpha}|\boldsymbol{\omega}|\boldsymbol{\omega} - \sin\theta,\tag{1.128}$$

$$\begin{aligned} \frac{d\theta}{d\bar{t}} &= \omega, \\ \bar{\mathbf{c}} &= \dots^2 + \dots, \end{aligned} \tag{1.129}$$

<sup>S</sup><sup>N</sup> D !<sup>2</sup> <sup>C</sup> cos -; (1.130)

$$
\bar{D} = -\alpha|\omega|\omega,\tag{1.131}
$$

with

$$
\alpha = \frac{C\_{D}\varrho\pi R^{2}L}{2m},
$$

as a dimensionless parameter expressing the ratio of the drag force and the gravity force. The dimensionless ! is made non-dimensional by the time, so ! pL=g is the corresponding angular frequency with dimensions.

A suitable function for computing (1.128)–(1.131) is listed below.

```
def simulate(alpha, Theta, dt, T):
    import odespy
    def f(u, t, alpha):
        omega, theta = u
        return [-alpha*omega*abs(omega) - sin(theta),
                omega]
```
<sup>18</sup> https://github.com/hplgit/pysketcher

<sup>19</sup> http://hplgit.github.io/pysketcher/doc/web/index.html

```
import numpy as np
Nt = int(round(T/float(dt)))
t = np.linspace(0, Nt*dt, Nt+1)
solver = odespy.RK4(f, f_args=[alpha])
solver.set_initial_condition([0, Theta])
u, t = solver.solve(
    t, terminate=lambda u, t, n: abs(u[n,1]) < 1E-3)
omega = u[:,0]
theta = u[:,1]
S = omega**2 + np.cos(theta)
drag = -alpha*np.abs(omega)*omega
return t, theta, omega, S, drag
```
**Drawing the free body diagram** The sketch function below applies Pysketcher objects to build a diagram like that in Fig. 1.21, except that we have removed the rotation point .x0; y0/ and the unit vectors in polar coordinates as these objects are not important for an animated free body diagram.

```
import sys
try:
    from pysketcher import *
except ImportError:
    print 'Pysketcher must be installed from'
    print 'https://github.com/hplgit/pysketcher'
    sys.exit(1)
# Overall dimensions of sketch
H = 15.
W = 17.
drawing_tool.set_coordinate_system(
    xmin=0, xmax=W, ymin=0, ymax=H,
    axis=False)
def sketch(theta, S, mg, drag, t, time_level):
    """
    Draw pendulum sketch with body forces at a time level
    corresponding to time t. The drag force is in
    drag[time_level], the force in the wire is S[time_level],
    the angle is theta[time_level].
    """
    import math
    a = math.degrees(theta[time_level]) # angle in degrees
    L = 0.4*H # Length of pendulum
    P = (W/2, 0.8*H) # Fixed rotation point
    mass_pt = path.geometric_features()['end']
    rod = Line(P, mass_pt)
    mass = Circle(center=mass_pt, radius=L/20.)
    mass.set_filled_curves(color='blue')
    rod_vec = rod.geometric_features()['end']-\
              rod.geometric_features()['start']
    unit_rod_vec = unit_vec(rod_vec)
    mass_symbol = Text('$m$', mass_pt + L/10*unit_rod_vec)
```

```
rod_start = rod.geometric_features()['start'] # Point P
vertical = Line(rod_start, rod_start + point(0,-L/3))
def set_dashed_thin_blackline(*objects):
    """Set linestyle of objects to dashed, black, width=1."""
    for obj in objects:
        obj.set_linestyle('dashed')
        obj.set_linecolor('black')
        obj.set_linewidth(1)
set_dashed_thin_blackline(vertical)
set_dashed_thin_blackline(rod)
angle = Arc_wText(r'$\theta$', rod_start, L/6, -90, a,
                  text_spacing=1/30.)
magnitude = 1.2*L/2 # length of a unit force in figure
force = mg[time_level] # constant (scaled eq: about 1)
force *= magnitude
mg_force = Force(mass_pt, mass_pt + force*point(0,-1),
                  '', text_pos='end')
force = S[time_level]
force *= magnitude
rod_force = Force(mass_pt, mass_pt - force*unit_vec(rod_vec),
                  '', text_pos='end',
                  text_spacing=(0.03, 0.01))
force = drag[time_level]
force *= magnitude
air_force = Force(mass_pt, mass_pt -
                  force*unit_vec((rod_vec[1], -rod_vec[0])),
                  '', text_pos='end',
                  text_spacing=(0.04,0.005))
body_diagram = Composition(
    {'mg': mg_force, 'S': rod_force, 'air': air_force,
     'rod': rod, 'body': mass
     'vertical': vertical, 'theta': angle,})
body_diagram.draw(verbose=0)
drawing_tool.savefig('tmp_%04d.png' % time_level, crop=False)
# (No cropping: otherwise movies will be very strange!)
```
**Making the animated free body diagram** It now remains to couple the simulate and sketch functions. We first run simulate:

```
from math import pi, radians, degrees
import numpy as np
alpha = 0.4
period = 2*pi # Use small theta approximation
T = 12*period # Simulate for 12 periods
dt = period/40 # 40 time steps per period
a = 70 # Initial amplitude in degrees
Theta = radians(a)
t, theta, omega, S, drag = simulate(alpha, Theta, dt, T)
```
The next step is to run through the time levels in the simulation and make a sketch at each level:

```
for time_level, t_ in enumerate(t):
    sketch(theta, S, mg, drag, t_, time_level)
```
The individual sketches are (by the sketch function) saved in files with names tmp\_%04d.png. These can be combined to videos using (e.g.) ffmpeg. A complete function animate for running the simulation and creating video files is listed below.

```
def animate():
    # Clean up old plot files
    import os, glob
    for filename in glob.glob('tmp_*.png') + glob.glob('movie.*'):
        os.remove(filename)
    # Solve problem
    from math import pi, radians, degrees
    import numpy as np
    alpha = 0.4
    period = 2*pi # Use small theta approximation
    T = 12*period # Simulate for 12 periods
    dt = period/40 # 40 time steps per period
    a = 70 # Initial amplitude in degrees
    Theta = radians(a)
    t, theta, omega, S, drag = simulate(alpha, Theta, dt, T)
    # Visualize drag force 5 times as large
    drag *= 5
    mg = np.ones(S.size) # Gravity force (needed in sketch)
    # Draw animation
    import time
    for time_level, t_ in enumerate(t):
        sketch(theta, S, mg, drag, t_, time_level)
        time.sleep(0.2) # Pause between each frame on the screen
    # Make videos
    prog = 'ffmpeg'
    filename = 'tmp_%04d.png'
    fps = 6
    codecs = {'flv': 'flv', 'mp4': 'libx264',
              'webm': 'libvpx', 'ogg': 'libtheora'}
    for ext in codecs:
        lib = codecs[ext]
        cmd = '%(prog)s -i %(filename)s -r %(fps)s ' % vars()
        cmd += '-vcodec %(lib)s movie.%(ext)s' % vars()
        print(cmd)
        os.system(cmd)
```
#### **1.12.7 Motion of an Elastic Pendulum**

Consider a pendulum as in Fig. 1.20, but this time the wire is elastic. The length of the wire when it is not stretched is L0, while L.t/ is the stretched length at time t during the motion.

Stretching the elastic wire a distance L gives rise to a spring force kL in the opposite direction of the stretching. Let n be a unit normal vector along the wire from the point <sup>r</sup><sup>0</sup> <sup>D</sup> .x0; y0/ and in the direction of <sup>i</sup> - , see Fig. 1.21 for definition of .x0; y0/ and i - . Obviously, we have <sup>n</sup> <sup>D</sup> <sup>i</sup> - , but in this modeling of an elastic pendulum we do not need polar coordinates. Instead, it is more straightforward to develop the equation in Cartesian coordinates.

A mathematical expression for n is

$$
\mathfrak{n} = \frac{r - r\_0}{L(t)},
$$

where L.t/ D jjr r<sup>0</sup>jj is the current length of the elastic wire. The position vector r in Cartesian coordinates reads r.t/ D x.t/i C y.t/j , where i and j are unit vectors in the x and y directions, respectively. It is convenient to introduce the Cartesian components nx and ny of the normal vector:

$$n = \frac{r - r\_0}{L(t)} = \frac{x(t) - x\_0}{L(t)}\dot{\mathbf{i}} + \frac{y(t) - y\_0}{L(t)}\dot{\mathbf{j}} = n\_x\dot{\mathbf{i}} + n\_y\dot{\mathbf{j}}\dots$$

The stretch L in the wire is

$$
\Delta t = L(t) - L\_0 \dots
$$

The force in the wire is then Sn D kLn.

The other forces are the gravity and the air resistance, just as in Fig. 1.21. For motion in air we can neglect the added mass and buoyancy effects. The main difference is that we have a *model* for S in terms of the motion (as soon as we have expressed L by r). For simplicity, we drop the air resistance term (but Exercise 1.27 asks you to include it).

Newton's second law of motion applied to the body now results in

$$
\hbar m \ddot{\mathbf{r}} = -k(L - L\_0)\mathbf{n} - mg \,\mathbf{j}\,\,. \tag{1.132}
$$

The two components of (1.132) are

$$
\ddot{\chi} = -\frac{k}{m}(L - L\_0)n\_\times,\tag{1.133}
$$

$$\ddot{\mathbf{y}} = -\frac{k}{m}(L - L\_0)\mathbf{n}\_\mathbf{y} - \mathbf{g} \,. \tag{1.134}$$

**Remarks about an elastic vs a non-elastic pendulum** Note that the derivation of the ODEs for an elastic pendulum is more straightforward than for a classical, non-elastic pendulum, since we avoid the details with polar coordinates, but instead work with Newton's second law directly in Cartesian coordinates. The reason why we can do this is that the elastic pendulum undergoes a general two-dimensional motion where all the forces are known or expressed as functions of x.t/ and y.t/, such that we get two ordinary differential equations. The motion of the non-elastic pendulum, on the other hand, is constrained: the body has to move along a circular path, and the force S in the wire is unknown.

The non-elastic pendulum therefore leads to a *differential-algebraic* equation, i.e., ODEs for x.t/ and y.t/ combined with an extra constraint .x x0/2 C .y y0/2 D <sup>L</sup><sup>2</sup> ensuring that the motion takes place along a circular path. The extra constraint (equation) is compensated by an extra unknown force Sn. Differentialalgebraic equations are normally hard to solve, especially with pen and paper. Fortunately, for the non-elastic pendulum we can do a trick: in polar coordinates the unknown force S appears only in the radial component of Newton's second law, while the unknown degree of freedom for describing the motion, the angle -.t/, is completely governed by the asimuthal component. This allows us to decouple the unknowns S and -. But this is a kind of trick and not a widely applicable method. With an elastic pendulum we use straightforward reasoning with Newton's 2nd law and arrive at a standard ODE problem that (after scaling) is easy to solve on a computer.

**Initial conditions** What is the initial position of the body? We imagine that first the pendulum hangs in equilibrium in its vertical position, and then it is displaced an angle . The equilibrium position is governed by the ODEs with the accelerations set to zero. The <sup>x</sup> component leads to x.t/ D x0, while the <sup>y</sup> component gives

$$0 = -\frac{k}{m}(L - L\_0)\mathbf{n}\_\mathbf{y} - \mathbf{g} = \frac{k}{m}(L(0) - L\_0) - \mathbf{g} \quad \Rightarrow \quad L(0) = L\_0 + m\mathbf{g}/k,\ \mathbf{n}\_\mathbf{y}$$

since ny D <sup>11</sup> in this position. The corresponding <sup>y</sup> value is then from ny D 1:

$$y(t) = y\_0 - L(0) = y\_0 - \left(L\_0 + mg/k\right).$$

Let us now choose .x0; y0/ such that the body is at the origin in the equilibrium position:

$$x\_0 = 0, \quad y\_0 = L\_0 + mg/k\,\,.$$

Displacing the body an angle  to the right leads to the initial position

$$\mathbf{x}(0) = (L\_0 + m\mathbf{g}/k)\sin\Theta, \quad \mathbf{y}(0) = (L\_0 + m\mathbf{g}/k)(1 - \cos\Theta).$$

The initial velocities can be set to zero: x<sup>0</sup> .0/ D <sup>y</sup><sup>0</sup> .0/ D <sup>0</sup>. **The complete ODE problem** We can summarize all the equations as follows:

$$\begin{aligned} \ddot{\mathbf{x}} &= -\frac{k}{m}(L - L\_0)n\_x, \\ \ddot{\mathbf{y}} &= -\frac{k}{m}(L - L\_0)n\_y - \mathbf{g}, \\ L &= \sqrt{(\mathbf{x} - \mathbf{x}\_0)^2 + (\mathbf{y} - \mathbf{y}\_0)^2}, \\ n\_x &= \frac{\mathbf{x} - \mathbf{x}\_0}{L}, \\ n\_y &= \frac{\mathbf{y} - \mathbf{y}\_0}{L}, \\ \mathbf{x}(0) &= (L\_0 + m\mathbf{g}/k)\sin\Theta, \\ \mathbf{x}'(0) &= 0, \\ \mathbf{y}(0) &= (L\_0 + m\mathbf{g}/k)(1 - \cos\Theta), \\ \mathbf{y}'(0) &= 0. \end{aligned}$$

We insert nx and ny in the ODEs:

$$\ddot{x} = -\frac{k}{m} \left( 1 - \frac{L\_0}{L} \right) (x - x\_0),\tag{1.135}$$

$$\ddot{\mathbf{y}} = -\frac{k}{m} \left( \mathbf{l} - \frac{L\_0}{L} \right) (\mathbf{y} - \mathbf{y}\_0) - \mathbf{g},\tag{1.136}$$

$$L = \sqrt{(\mathbf{x} - \mathbf{x}\_0)^2 + (\mathbf{y} - \mathbf{y}\_0)^2},\tag{1.137}$$

$$\mathbf{x}(0) = (L\_0 + m\mathbf{g}/k)\sin\Theta,\tag{1.138}$$

$$\mathbf{x}\_{1\text{-loop}} = \sum\_{\text{all terms}} \mathbf{x}\_{\text{all terms}} \tag{1.139}$$

$$\mathbf{x}'(0) = 0,\tag{1.139}$$

$$\mathbf{x}'(0) = (1 + \dots + \dots) \ge 0.\tag{1.130}$$

$$\mathbf{y}(0) = (L\_0 + m\mathbf{g}/k)(1 - \cos\Theta),\tag{1.140}$$

$$\mathbf{y}'(0) = \mathbf{0}.\tag{1.141}$$

**Scaling** The elastic pendulum model can be used to study both an elastic pendulum and a classic, non-elastic pendulum. The latter problem is obtained by letting <sup>k</sup> ! 1. Unfortunately, a serious problem with the ODEs (1.135)–(1.136) is that for large <sup>k</sup>, we have a very large factor k=m multiplied by a very small number <sup>1</sup> L0=L, since for large <sup>k</sup>, <sup>L</sup> L0 (very small deformations of the wire). The product is subject to significant round-off errors for many relevant physical values of the parameters. To circumvent the problem, we introduce a scaling. This will also remove physical parameters from the problem such that we end up with only one dimensionless parameter, closely related to the elasticity of the wire. Simulations can then be done by setting just this dimensionless parameter.

The characteristic length can be taken such that in equilibrium, the scaled length is unity, i.e., the characteristic length is L0 <sup>C</sup> mg=k:

$$
\bar{\chi} = \frac{x}{L\_0 + mg/k}, \quad \bar{\chi} = \frac{y}{L\_0 + mg/k}.
$$

We must then also work with the scaled length <sup>L</sup><sup>N</sup> <sup>D</sup> L=.L0 <sup>C</sup> mg=k/.

Introducing t <sup>N</sup> <sup>D</sup> t=tc , where tc is a characteristic time we have to decide upon later, one gets

$$\begin{aligned} \frac{d^2 \bar{\chi}}{d\bar{t}^2} &= -t\_c^2 \frac{k}{m} \left( 1 - \frac{L\_0}{L\_0 + mg/k} \frac{1}{\bar{L}} \right) \bar{\chi}, \\\frac{d^2 \bar{\chi}}{d\bar{t}^2} &= -t\_c^2 \frac{k}{m} \left( 1 - \frac{L\_0}{L\_0 + mg/k} \frac{1}{\bar{L}} \right) (\bar{\chi} - 1) - t\_c^2 \frac{g}{L\_0 + mg/k}, \\\bar{L} &= \sqrt{\bar{\chi}^2 + (\bar{\chi} - 1)^2}, \\\bar{\chi}(0) &= \sin \Theta, \\\bar{\chi}'(0) &= 0, \\\bar{\chi}'(0) &= 1 - \cos \Theta, \\\bar{\chi}'(0) &= 0. \end{aligned}$$

For a non-elastic pendulum with small angles, we know that the frequency of the oscillations are ! <sup>D</sup> <sup>p</sup>L=g. It is therefore natural to choose a similar expression here, either the length in the equilibrium position,

$$t\_c^2 = \frac{L\_0 + mg/k}{\mathcal{g}}.$$

or simply the unstretched length,

$$t\_c^2 = \frac{L\_0}{\mathbf{g}} \, .$$

These quantities are not very different (since the elastic model is valid only for quite small elongations), so we take the latter as it is the simplest one.

The ODEs become

$$\begin{aligned} \frac{d^2 \bar{\mathbf{x}}}{d \bar{t}^2} &= -\frac{L\_0 k}{mg} \left( 1 - \frac{L\_0}{L\_0 + mg/k} \frac{1}{\bar{L}} \right) \bar{\mathbf{x}},\\ \frac{d^2 \bar{\mathbf{y}}}{d \bar{t}^2} &= -\frac{L\_0 k}{mg} \left( 1 - \frac{L\_0}{L\_0 + mg/k} \frac{1}{\bar{L}} \right) (\bar{\mathbf{y}} - 1) - \frac{L\_0}{L\_0 + mg/k} \bar{\mathbf{y}},\\ \bar{L} &= \sqrt{\bar{\mathbf{x}}^2 + (\bar{\mathbf{y}} - 1)^2}.\end{aligned}$$

We can now identify a dimensionless number

$$\beta = \frac{L\_0}{L\_0 + mg/k} = \frac{1}{1 + \frac{mg}{L\_0k}}$$

;

which is the ratio of the unstretched length and the stretched length in equilibrium. The non-elastic pendulum will have <sup>ˇ</sup> D <sup>1</sup> (<sup>k</sup> ! 1). With <sup>ˇ</sup> the ODEs read

$$\frac{d^2\bar{\mathbf{x}}}{d\bar{t}^2} = -\frac{\beta}{1-\beta} \left(1 - \frac{\beta}{\bar{L}}\right) \bar{\mathbf{x}},\tag{1.142}$$

$$\frac{d^2\bar{\mathbf{y}}}{d\bar{t}^2} = -\underbrace{\frac{\beta}{1-\beta}\left(1-\frac{\beta}{\bar{L}}\right)(\bar{\mathbf{y}}-1)-\beta}\_{\mathbf{1}-\mathbf{a}^2-\mathbf{b}^2} \tag{1.143}$$

$$\bar{L} = \sqrt{\bar{\chi}^2 + (\bar{\chi} - 1)^2},\tag{1.144}$$

$$
\bar{\mathbf{x}}(0) = (1 + \epsilon) \sin \Theta,\tag{1.145}
$$

$$\frac{d\bar{X}}{d\bar{t}}(0) = 0,\tag{1.146}$$

$$\begin{cases} \dot{\mathbf{y}}(0) = 1 - (1 + \epsilon) \cos \Theta, \\ \mathbf{y} \end{cases} \tag{1.147}$$

$$\frac{d\,\bar{y}}{d\,\bar{t}}(0) = 0,\tag{1.148}$$

We have here added a parameter , which is an additional downward stretch of the wire at <sup>t</sup> D <sup>0</sup>. This parameter makes it possible to do a desired test: vertical oscillations of the pendulum. Without , starting the motion from .0; 0/ with zero velocity will result in <sup>x</sup> D <sup>y</sup> D <sup>0</sup> for all times (also a good test!), but with an initial stretch so the body's position is .0; 
/, we will have oscillatory vertical motion with amplitude (see Exercise 1.26).

**Remark on the non-elastic limit** We immediately see that as <sup>k</sup> ! 1 (i.e., we obtain a non-elastic pendulum), <sup>ˇ</sup> ! <sup>1</sup>, <sup>L</sup><sup>N</sup> ! <sup>1</sup>, and we have very small values <sup>1</sup> ˇL<sup>N</sup> <sup>1</sup> divided by very small values <sup>1</sup> <sup>ˇ</sup> in the ODEs. However, it turns out that we can set ˇ very close to one and obtain a path of the body that within the visual accuracy of a plot does not show any elastic oscillations. (Should the division of very small values become a problem, one can study the limit by L'Hospital's rule:

$$\lim\_{\beta \to 1} \frac{1 - \beta \bar{L}^{-1}}{1 - \beta} = \frac{1}{\bar{L}},$$

and use the limit <sup>L</sup><sup>N</sup> <sup>1</sup> in the ODEs for <sup>ˇ</sup> values very close to 1.)

#### **1.12.8 Vehicle on a Bumpy Road**

We consider a very simplistic vehicle, on one wheel, rolling along a bumpy road. The oscillatory nature of the road will induce an external forcing on the spring system in the vehicle and cause vibrations. Figure 1.22 outlines the situation.

To derive the equation that governs the motion, we must first establish the position vector of the black mass at the top of the spring. Suppose the spring has length L without any elongation or compression, suppose the radius of the wheel is R, and suppose the height of the black mass at the top is <sup>H</sup>. With the aid of the <sup>r</sup><sup>0</sup> vector in Fig. 1.22, the position r of the center point of the mass is

$$\mathbf{r} = \mathbf{r}\_0 + 2R\mathbf{j} + L\mathbf{j} + u\mathbf{j} + \frac{1}{2}H\mathbf{j},\tag{1.149}$$

**Fig. 1.22** Sketch of onewheel vehicle on a bumpy road

where u is the elongation or compression in the spring according to the (unknown and to be computed) vertical displacement u relative to the road. If the vehicle travels with constant horizontal velocity v and h.x/ is the shape of the road, then the vector <sup>r</sup><sup>0</sup> is

$$r\_0 = vt\dot{\mathbf{i}} + h(vt)\dot{\mathbf{j}}\,,$$

if the motion starts from <sup>x</sup> D <sup>0</sup> at time <sup>t</sup> D <sup>0</sup>.

The forces on the mass is the gravity, the spring force, and an optional damping force that is proportional to the vertical velocity <sup>u</sup>P. Newton's second law of motion then tells that

$$m\ddot{\mathbf{r}} = -m\mathbf{g}\,\dot{\mathbf{j}} - s(\boldsymbol{u}) - b\dot{u}\,\dot{\mathbf{j}}\,\boldsymbol{\.}$$

This leads to

$$m\ddot{u} = -s(u) - b\dot{u} - mg - mh''(vt)v^2 \ .$$

To simplify a little bit, we omit the gravity force mg in comparison with the other terms. Introducing <sup>u</sup><sup>0</sup> for <sup>u</sup>P then gives a standard damped, vibration equation with external forcing:

$$
tau'' + bu' + s(u) = -mh''(vt)v^2.\tag{1.150}
$$

Since the road is normally known just as a set of array values, h<sup>00</sup> must be computed by finite differences. Let x be the spacing between measured values hi <sup>D</sup> h.ix/ on the road. The discrete second-order derivative h<sup>00</sup> reads

$$q\_i = \frac{h\_{i-1} - 2h\_i + h\_{i+1}}{\Delta x^2}, \quad i = 1, \dots, N\_x - 1 \dots$$

We may for maximum simplicity set the end points as q0 <sup>D</sup> q1 and qNx <sup>D</sup> qNx<sup>1</sup>. The term mh00.vt/v<sup>2</sup> corresponds to a force with discrete time values

$$F^n = -mq\_n v^2, \quad \Delta t = v^{-1} \Delta x.$$

This force can be directly used in a numerical model

$$[mD\_lD\_l\mu + bD\_{2l}\mu + s(\mu) = F]^n \dots$$

Software for computing u and also making an animated sketch of the motion like we did in Sect. 1.12.6 is found in a separate project on the web: https://github.com/hplgit/bumpy. You may start looking at the tutorial20.

#### **1.12.9 Bouncing Ball**

A bouncing ball is a ball in free vertically fall until it impacts the ground, but during the impact, some kinetic energy is lost, and a new motion upwards with reduced velocity starts. After the motion is retarded, a new free fall starts, and the process is repeated. At some point the velocity close to the ground is so small that the ball is considered to be finally at rest.

The motion of the ball falling in air is governed by Newton's second law <sup>F</sup> D ma, where a is the acceleration of the body, m is the mass, and F is the sum of all forces. Here, we neglect the air resistance so that gravity mg is the only force. The height of the ball is denoted by h and v is the velocity. The relations between h, v, and a,

$$h'(t) = v(t), \quad v'(t) = a(t),$$

combined with Newton's second law gives the ODE model

$$h''(t) = -\mathbf{g},\tag{1.151}$$

or expressed alternatively as a system of first-order equations:

$$v'(t) = -\mathbf{g},\tag{1.152}$$

$$h'(t) = v(t) \,. \tag{1.153}$$

These equations govern the motion as long as the ball is away from the ground by a small distance h > 0. When h<
h, we have two cases.

v ) close to the ground.

#### **1.12.10 Two-Body Gravitational Problem**

Consider two astronomical objects A and B that attract each other by gravitational forces. A and B could be two stars in a binary system, a planet orbiting a star, or a moon orbiting a planet. Each object is acted upon by the gravitational force due to the other object. Consider motion in a plane (for simplicity) and let .xA; yA/ and .xB ; yB / be the positions of object A and B, respectively.

<sup>20</sup> http://hplgit.github.io/bumpy/doc/pub/bumpy.pdf

<sup>21</sup> http://en.wikipedia.org/wiki/Coefficient\_of\_restitution

**The governing equations** Newton's second law of motion applied to each object is all we need to set up a mathematical model for this physical problem:

$$\begin{array}{c} m\_A \ddot{\mathbf{x}}\_A = F, \\ \mathbf{\ldots} \end{array} \tag{1.154}$$

$$m\_B \ddot{\mathbf{x}}\_B = -F,\tag{1.155}$$

where F is the gravitational force

$$F = \frac{Gm\_A m\_B}{||r||^3}r,$$

where

$$r(t) = \mathbf{x}\_B(t) - \mathbf{x}\_A(t),$$

and <sup>G</sup> is the gravitational constant: <sup>G</sup> D 6:674 <sup>10</sup><sup>11</sup> Nm2=kg<sup>2</sup> .

**Scaling** A problem with these equations is that the parameters are very large (mA, mB , jjrjj) or very small (G). The rotation time for binary stars can be very small and large as well. It is therefore advantageous to scale the equations. A natural length scale could be the initial distance between the objects: <sup>L</sup> D r.0/. We write the dimensionless quantities as

$$
\bar{\mathbf{x}}\_A = \frac{\mathbf{x}\_A}{L}, \quad \bar{\mathbf{x}}\_B = \frac{\mathbf{x}\_B}{L}, \quad \bar{t} = \frac{t}{t\_c}.
$$

The gravity force is transformed to

$$F = \frac{Gm\_A m\_B}{L^2 ||\bar{r}||^3} \bar{r}, \quad \bar{r} = \bar{x}\_B - \bar{x}\_A, \bar{r}$$

so the first ODE for <sup>x</sup><sup>A</sup> becomes

$$\frac{d^2\bar{\mathbf{x}}\_A}{d\bar{t}^2} = \frac{Gm\_Bt\_c^2}{L^3}\frac{\bar{r}}{||\bar{r}||^3}.$$

Assuming that quantities with a bar and their derivatives are around unity in size, it is natural to choose tc such that the fraction GmB tc=L<sup>2</sup> <sup>D</sup> <sup>1</sup>:

$$t\_c = \sqrt{\frac{L^3}{Gm\_B}}\ .$$

From the other equation for <sup>x</sup><sup>B</sup> we get another candidate for tc with mA instead of mB . Which mass we choose play a role if mA mB or mB mA. One solution is to use the sum of the masses:

$$t\_c = \sqrt{\frac{L^3}{G(m\_A + m\_B)}} \cdot \frac{1}{\cdot}$$

Taking a look at Kepler's laws22 of planetary motion, the orbital period for a planet around the star is given by the tc above, except for a missing factor of 2, but that means that t<sup>1</sup> <sup>c</sup> is just the angular frequency of the motion. Our characteristic time tc is therefore highly relevant. Introducing the dimensionless number

$$
\alpha = \frac{m\_A}{m\_B},
$$

we can write the dimensionless ODE as

$$\frac{d^2\bar{\mathbf{x}}\_A}{d\bar{t}^2} = \frac{1}{1+\alpha} \frac{\bar{r}}{||\bar{r}||^3},\tag{1.156}$$

$$\frac{d^2\bar{\mathfrak{x}}\_B}{d\bar{t}^2} = \frac{1}{1 + \alpha^{-1}} \frac{\bar{r}}{||\bar{r}||^3} \,. \tag{1.157}$$

In the limit mA mB , i.e., ˛ <sup>1</sup>, object B stands still, say <sup>x</sup><sup>N</sup> <sup>B</sup> <sup>D</sup> <sup>0</sup>, and object A orbits according to

$$\frac{d^2\bar{\mathbf{x}}\_A}{d\bar{t}^2} = -\frac{\bar{\mathbf{x}}\_A}{||\bar{\mathbf{x}}\_A||^3} \cdot \mathbf{l}$$

**Solution in a special case: planet orbiting a star** To better see the motion, and that our scaling is reasonable, we introduce polar coordinates r and -:

$$
\bar{\mathbf{x}}\_A = r \cos \theta \mathbf{i} + r \sin \theta \,\mathbf{j},
$$

which means <sup>x</sup>N<sup>A</sup> can be written as <sup>x</sup>N<sup>A</sup> <sup>D</sup> <sup>r</sup><sup>i</sup> <sup>r</sup> . Since

$$\frac{d}{dt}\dot{\mathbf{r}}\_r = \dot{\theta}\dot{\mathbf{r}}\_\theta, \quad \frac{d}{dt}\dot{\mathbf{r}}\_\theta = -\dot{\theta}\dot{\mathbf{r}}\_r,$$

we have

$$\frac{d^2\vec{\mathbf{x}}\_A}{d\vec{t}^2} = (\vec{r} - r\dot{\theta}^2)\mathbf{i}\_r + (r\ddot{\theta} + 2\dot{r}\dot{\theta})\mathbf{i}\_\theta \dots$$
 
$$\dots \quad \dots \quad \dots \quad \dots$$

The equation of motion for mass A is then

$$\begin{aligned} \ddot{r} - r\dot{\theta}^2 &= -\frac{1}{r^2}, \\ r\ddot{\theta} + 2\dot{r}\dot{\theta} &= 0. \end{aligned}$$

The special case of circular motion, <sup>r</sup> D <sup>1</sup>, fulfills the equations, since the latter equation then gives <sup>P</sup> - D const and the former then gives <sup>P</sup> - D <sup>1</sup>, i.e., the motion is r .t/ D <sup>1</sup>, -.t/ D <sup>t</sup>, with unit angular frequency as expected and period 2 as expected.

<sup>22</sup> https://en.wikipedia.org/wiki/Kepler%27s\_laws\_of\_planetary\_motion

#### **1.12.11 Electric Circuits**

Although the term "mechanical vibrations" is used in the present book, we must mention that the same type of equations arise when modeling electric circuits. The current I.t/ in a circuit with an inductor with inductance L, a capacitor with capacitance C, and overall resistance R, is governed by

$$
\ddot{I} + \frac{R}{L}\dot{I} + \frac{1}{LC}I = \dot{V}(t),
\tag{1.158}
$$

where V .t/ is the voltage source powering the circuit. This equation has the same form as the general model considered in Sect. 1.10 if we set <sup>u</sup> D <sup>I</sup> , f .u<sup>0</sup> / D bu<sup>0</sup> and define <sup>b</sup> D R=L, s.u/ D <sup>L</sup>1C 1u, and F .t/ D V .t/ <sup>P</sup> .

#### **1.13 Exercises**

#### **Exercise 1.22: Simulate resonance**

We consider the scaled ODE model (1.122) from Sect. 1.12.2. After scaling, the amplitude of u will have a size about unity as time grows and the effect of the initial conditions die out due to damping. However, as ! <sup>1</sup>, the amplitude of <sup>u</sup> increases, especially if ˇ is small. This effect is called *resonance*. The purpose of this exercise is to explore resonance.


Filename: resonance.

#### **Exercise 1.23: Simulate oscillations of a sliding box**

Consider a sliding box on a flat surface as modeled in Sect. 1.12.3. As spring force we choose the nonlinear formula

$$ds(u) = \frac{k}{\alpha} \tanh(\alpha u) = ku + \frac{1}{3}\alpha^2 k u^3 + \frac{2}{15}\alpha^4 k u^5 + \mathcal{O}(u^6) \dots$$


Filename: sliding\_box.

#### **Exercise 1.24: Simulate a bouncing ball**

Section 1.12.9 presents a model for a bouncing ball. Choose one of the two ODE formulation, (1.151) or (1.152)–(1.153), and simulate the motion of a bouncing ball. Plot h.t/. Think about how to plot v.t/.

*Hint* A naive implementation may get stuck in repeated impacts for large time step sizes. To avoid this situation, one can introduce a state variable that holds the mode of the motion: free fall, impact, or rest. Two consecutive impacts imply that the motion has stopped.

Filename: bouncing\_ball.

#### **Exercise 1.25: Simulate a simple pendulum**

Simulation of simple pendulum can be carried out by using the mathematical model derived in Sect. 1.12.5 and calling up functionality in the vib.py file (i.e., solve the second-order ODE by centered finite differences).


Filename: simple\_pendulum.

#### **Exercise 1.26: Simulate an elastic pendulum**

Section 1.12.7 describes a model for an elastic pendulum, resulting in a system of two ODEs. The purpose of this exercise is to implement the scaled model, test the software, and generalize the model.

a) Write a function simulate that can simulate an elastic pendulum using the scaled model. The function should have the following arguments:

```
def simulate(
   beta=0.9, # dimensionless parameter
   Theta=30, # initial angle in degrees
   epsilon=0, # initial stretch of wire
   num_periods=6, # simulate for num_periods
   time_steps_per_period=60, # time step resolution
   plot=True, # make plots or not
   ):
```
To set the total simulation time and the time step, we use our knowledge of the scaled, classical, non-elastic pendulum: <sup>u</sup><sup>00</sup> C <sup>u</sup> D <sup>0</sup>, with solution <sup>u</sup> D  cos<sup>t</sup> N. The period of these oscillations is <sup>P</sup> D 2 and the frequency is unity. The time for simulation is taken as num\_periods times P. The time step is set as P divided by time\_steps\_per\_period.

The simulate function should return the arrays of x, y, -, and t, where - D tan1.x=.1 y// is the angular displacement of the elastic pendulum corresponding to the position .x; y/.

If plot is True, make a plot of y.N t/<sup>N</sup> versus x.N t /<sup>N</sup> , i.e., the physical motion of the mass at .x;N y/N . Use the equal aspect ratio on the axis such that we get a physically correct picture of the motion. Also make a plot of -.t/<sup>N</sup> , where is measured in degrees. If  < 10 degrees, add a plot that compares the solutions of the scaled, classical, non-elastic pendulum and the elastic pendulum (-.t/). Although the mathematics here employs a bar over scaled quantities, the code should feature plain names <sup>x</sup> for <sup>x</sup>N, <sup>y</sup> for <sup>y</sup>N, and <sup>t</sup> for <sup>t</sup> <sup>N</sup> (rather than x\_bar, etc.). These variable names make the code easier to read and compare with the

*Hint 1* Equal aspect ratio is set by plt.gca().set\_aspect('equal') in Matplotlib (import matplotlib.pyplot as plt) and in SciTools by the command plt.plot(..., daspect=[1,1,1], daspectmode='equal') (provided you have done import scitools.std as plt).

*Hint 2* If you want to use Odespy to solve the equations, order the ODEs like PNx; x;N PNy; <sup>y</sup>N such that odespy.EulerCromer can be applied.


Write a test function that uses this special case to verify the simulate function. There will be numerical approximation errors present in the results from simulate so you have to believe in correct results and set a (low) tolerance that corresponds to the computed maximum error. Use a small t to obtain a small numerical approximation error.

d) Make a function demo(beta, Theta) for simulating an elastic pendulum with a given ˇ parameter and initial angle . Use 600 time steps per period to get every accurate results, and simulate for 3 periods.

Filename: elastic\_pendulum.

#### **Exercise 1.27: Simulate an elastic pendulum with air resistance**

This is a continuation Exercise 1.26. Air resistance on the body with mass m can be modeled by the force <sup>1</sup> <sup>2</sup>%CDAjvjv, where CD is a drag coefficient (0.2 for a sphere), % is the density of air (1.2 kg m<sup>3</sup>), <sup>A</sup> is the cross section area (<sup>A</sup> D R<sup>2</sup> for a sphere, where <sup>R</sup> is the radius), and v is the velocity of the body. Include air resistance in the original model, scale the model, write a function simulate\_drag that is a copy of the simulate function from Exercise 1.26, but with the new ODEs included, and show plots of how air resistance influences the motion. Filename: elastic\_pendulum\_drag.

*Remarks* Test functions are challenging to construct for the problem with air resistance. You can reuse the tests from Exercise 1.27 for simulate\_drag, but these tests does not verify the new terms arising from air resistance.

mathematics.

#### **Exercise 1.28: Implement the PEFRL algorithm**

We consider the motion of a planet around a star (Sect. 1.12.10). The simplified case where one mass is very much bigger than the other and one object is at rest, results in the scaled ODE model

$$\begin{aligned} \ddot{\mathfrak{x}} + (\mathfrak{x}^2 + \mathfrak{y}^2)^{-3/2} \mathfrak{x} &= 0, \\ \ddot{\mathfrak{y}} + (\mathfrak{x}^2 + \mathfrak{y}^2)^{-3/2} \mathfrak{y} &= 0. \end{aligned}$$


Now, predict the error and required CPU time for a simulation of 1 billion years (orbits). Is it feasible on today's computers to simulate the planetary motion for one billion years?

Filename: vib\_PEFRL.

*Remarks* This exercise investigates whether it is feasible to predict planetary motion for the life time of a solar system.

**Open Access** This chapter is licensed under the terms of the Creative Commons Attribution 4.0 International License (http://creativecommons.org/licenses/by/4.0/), which permits use, sharing, adaptation, distribution and reproduction in any medium or format, as long as you give appropriate credit to the original author(s) and the source, provide a link to the Creative Commons license and indicate if changes were made.

The images or other third party material in this chapter are included in the chapter's Creative Commons license, unless indicated otherwise in a credit line to the material. If material is not included in the chapter's Creative Commons license and your intended use is not permitted by statutory regulation or exceeds the permitted use, you will need to obtain permission directly from the copyright holder.

# **2 Wave Equations**

A very wide range of physical processes lead to wave motion, where signals are propagated through a medium in space and time, normally with little or no permanent movement of the medium itself. The shape of the signals may undergo changes as they travel through matter, but usually not so much that the signals cannot be recognized at some later point in space and time. Many types of wave motion can be described by the equation utt Dr.c<sup>2</sup>ru/ <sup>C</sup> <sup>f</sup> , which we will solve in the forthcoming text by finite difference methods.

#### **2.1 Simulation of Waves on a String**

We begin our study of wave equations by simulating one-dimensional waves on a string, say on a guitar or violin. Let the string in the undeformed state coincide with the interval Œ0; L on the x axis, and let u.x; t/ be the displacement at time t in the y direction of a point initially at x. The displacement function u is governed by the mathematical model

$$\frac{\partial^2 u}{\partial t^2} = c^2 \frac{\partial^2 u}{\partial x^2}, \qquad \qquad \qquad \ge (0, L), \ t \in (0, T] \tag{2.1}$$

$$u(\mathbf{x},0) = I(\mathbf{x}), \quad \qquad \qquad \mathbf{x} \in [0,L] \tag{2.2}$$

$$\frac{\partial}{\partial t}u(\mathbf{x},0) = 0,\tag{2.3}$$

$$u(0,t) = 0, \quad \qquad \qquad t \in (0,T] \tag{2.4}$$

$$u(L,t) = 0, \qquad \qquad t \in (0,T]. \tag{2.5}$$

The constant c and the function I.x/ must be prescribed.

Equation (2.1) is known as the one-dimensional *wave equation*. Since this PDE contains a second-order derivative in time, we need *two initial conditions*. The condition (2.2) specifies the initial shape of the string, I.x/, and (2.3) expresses that the initial velocity of the string is zero. In addition, PDEs need *boundary conditions*, given here as (2.4) and (2.5). These two conditions specify that the string is fixed at the ends, i.e., that the displacement u is zero.

The solution u.x; t/ varies in space and time and describes waves that move with velocity c to the left and right.

H.P. Langtangen, S. Linge, *Finite Difference Computing with PDEs*,

Texts in Computational Science and Engineering 16, DOI 10.1007/978-3-319-55456-3\_2

Sometimes we will use a more compact notation for the partial derivatives to save space:

$$u\_l = \frac{\partial u}{\partial t}, \quad u\_{ll} = \frac{\partial^2 u}{\partial t^2}, \tag{2.6}$$

and similar expressions for derivatives with respect to other variables. Then the wave equation can be written compactly as utt <sup>D</sup> <sup>c</sup>2uxx.

The PDE problem (2.1)–(2.5) will now be discretized in space and time by a finite difference method.

#### **2.1.1 Discretizing the Domain**

The temporal domain Œ0; T is represented by a finite number of mesh points

$$0 = t\_0 < t\_1 < t\_2 < \dots < t\_{N\_l - 1} < t\_{N\_l} = T \,. \tag{2.7}$$

Similarly, the spatial domain Œ0; L is replaced by a set of mesh points

$$0 = \mathbf{x}\_0 < \mathbf{x}\_1 < \mathbf{x}\_2 < \dots < \mathbf{x}\_{N\_\lambda - 1} < \mathbf{x}\_{N\_\lambda} = L \,. \tag{2.8}$$

One may view the mesh as two-dimensional in the x;t plane, consisting of points .xi ; tn/, with <sup>i</sup> <sup>D</sup> 0; : : : ; Nx and <sup>n</sup> <sup>D</sup> 0; : : : ; Nt .

**Uniform meshes** For uniformly distributed mesh points we can introduce the constant mesh spacings t and x. We have that

$$\Delta x\_i = i \, \Delta x, \; i = 0, \dots, N\_{\chi}, \quad t\_n = n \, \Delta t, \; n = 0, \dots, N\_l \,. \tag{2.9}$$

We also have that x <sup>D</sup> xi xi<sup>1</sup>, <sup>i</sup> <sup>D</sup> 1; : : : ; Nx, and t <sup>D</sup> tn tn<sup>1</sup>, <sup>n</sup> <sup>D</sup> 1; : : : ; Nt . Figure 2.1 displays a mesh in the x;t plane with Nt <sup>D</sup> <sup>5</sup>, Nx <sup>D</sup> <sup>5</sup>, and constant mesh spacings.

#### **2.1.2 The Discrete Solution**

The solution u.x; t/ is sought at the mesh points. We introduce the mesh function u<sup>n</sup> <sup>i</sup> , which approximates the exact solution at the mesh point .xi ; tn/ for <sup>i</sup> <sup>D</sup> 0; : : : ; Nx and <sup>n</sup> <sup>D</sup> 0; : : : ; Nt . Using the finite difference method, we shall develop algebraic equations for computing the mesh function.

#### **2.1.3 Fulfilling the Equation at the Mesh Points**

In the finite difference method, we relax the condition that (2.1) holds at all points in the space-time domain .0; L/-.0; T to the requirement that the PDE is fulfilled at the *interior* mesh points only:

$$\frac{\partial^2}{\partial t^2} u(\mathbf{x}\_i, t\_n) = c^2 \frac{\partial^2}{\partial \mathbf{x}^2} u(\mathbf{x}\_i, t\_n), \tag{2.10}$$

for <sup>i</sup> <sup>D</sup> 1; : : : ; Nx <sup>1</sup> and <sup>n</sup> <sup>D</sup> 1; : : : ; Nt <sup>1</sup>. For <sup>n</sup> <sup>D</sup> <sup>0</sup> we have the initial conditions <sup>u</sup> <sup>D</sup> I.x/ and ut <sup>D</sup> <sup>0</sup>, and at the boundaries <sup>i</sup> <sup>D</sup> 0; Nx we have the boundary condition <sup>u</sup> D <sup>0</sup>.

#### **2.1.4 Replacing Derivatives by Finite Differences**

The second-order derivatives can be replaced by central differences. The most widely used difference approximation of the second-order derivative is

$$\frac{\partial^2}{\partial t^2} \mu(\mathbf{x}\_i, t\_n) \approx \frac{\mu\_i^{n+1} - 2\mu\_i^n + \mu\_i^{n-1}}{\Delta t^2}.$$

It is convenient to introduce the finite difference operator notation

$$[D\_l D\_l u]\_i^n = \frac{u\_i^{n+1} - 2u\_i^n + u\_i^{n-1}}{\Delta t^2}.$$

A similar approximation of the second-order derivative in the x direction reads

$$\frac{\partial^2}{\partial \boldsymbol{x}^2} \boldsymbol{u}(\boldsymbol{x}\_i, t\_n) \approx \frac{\boldsymbol{u}\_{i+1}^n - 2\boldsymbol{u}\_i^n + \boldsymbol{u}\_{i-1}^n}{\Delta \boldsymbol{x}^2} = [D\_{\boldsymbol{x}} D\_{\boldsymbol{x}} \boldsymbol{u}]\_i^n \dots$$

**Algebraic version of the PDE** We can now replace the derivatives in (2.10) and get

$$\frac{u\_i^{n+1} - 2u\_i^n + u\_i^{n-1}}{\Delta t^2} = c^2 \frac{u\_{i+1}^n - 2u\_i^n + u\_{i-1}^n}{\Delta x^2},\tag{2.11}$$

or written more compactly using the operator notation:

$$\left[D\_l D\_l u = c^2 D\_x D\_x\right]\_l^n \,. \tag{2.12}$$

**Interpretation of the equation as a stencil** A characteristic feature of (2.11) is that it involves u values from neighboring points only: u<sup>n</sup>C<sup>1</sup> <sup>i</sup> , u<sup>n</sup> <sup>i</sup>˙<sup>1</sup>, <sup>u</sup><sup>n</sup> <sup>i</sup> , and u<sup>n</sup><sup>1</sup> <sup>i</sup> . The circles in Fig. 2.1 illustrate such neighboring mesh points that contribute to an algebraic equation. In this particular case, we have sampled the PDE at the point .2; 2/ and constructed (2.11), which then involves a coupling of u<sup>2</sup> <sup>1</sup>, u<sup>3</sup> <sup>2</sup>, u<sup>2</sup> <sup>2</sup>, u<sup>1</sup> <sup>2</sup>, and u2 <sup>3</sup>. The term *stencil* is often used about the algebraic equation at a mesh point, and the geometry of a typical stencil is illustrated in Fig. 2.1. One also often refers to the algebraic equations as *discrete equations*, *(finite) difference equations* or a *finite difference scheme*.

**Algebraic version of the initial conditions** We also need to replace the derivative in the initial condition (2.3) by a finite difference approximation. A centered difference of the type

$$\frac{\partial}{\partial t}\mu(\mathbf{x}\_i, t\_0) \approx \frac{\boldsymbol{u}\_i^1 - \boldsymbol{u}\_i^{-1}}{2\Delta t} = [D\_{2l}\boldsymbol{u}]\_l^0,$$

**Fig. 2.1** Mesh in space and time. The circles show points connected in a finite difference equation

seems appropriate. Writing out this equation and ordering the terms give

$$u\_i^{-1} = u\_i^1, \quad i = 0, \ldots, N\_{\chi}. \tag{2.13}$$

The other initial condition can be computed by

$$u\_i^0 = I(\mathbf{x}\_i), \quad i = 0, \dots, N\_{\times}.$$

#### **2.1.5 Formulating a Recursive Algorithm**

We assume that u<sup>n</sup> <sup>i</sup> and u<sup>n</sup><sup>1</sup> <sup>i</sup> are available for <sup>i</sup> <sup>D</sup> 0; : : : ; Nx. The only unknown quantity in (2.11) is therefore u<sup>n</sup>C<sup>1</sup> <sup>i</sup> , which we now can solve for:

$$u\_{i}^{n+1} = -u\_{i}^{n-1} + 2u\_{i}^{n} + C^{2} \left( u\_{i+1}^{n} - 2u\_{i}^{n} + u\_{i-1}^{n} \right) . \tag{2.14}$$

We have here introduced the parameter

$$C = c \frac{\Delta t}{\Delta x},\tag{2.15}$$

known as the *Courant number*.

## C **is the key parameter in the discrete wave equation**

We see that the discrete version of the PDE features only one parameter, C, which is therefore the key parameter, together with Nx, that governs the quality of the numerical solution (see Sect. 2.10 for details). Both the primary physical parameter c and the numerical parameters x and t are lumped together in C. Note that C is a dimensionless parameter.

**Fig. 2.2** Modified stencil for the first time step

Given that u<sup>n</sup><sup>1</sup> <sup>i</sup> and u<sup>n</sup> <sup>i</sup> are known for <sup>i</sup> <sup>D</sup> 0; : : : ; Nx, we find new values at the next time level by applying the formula (2.14) for <sup>i</sup> <sup>D</sup> 1; : : : ; Nx <sup>1</sup>. Figure 2.1 illustrates the points that are used to compute u<sup>3</sup> <sup>2</sup>. For the boundary points, <sup>i</sup> D <sup>0</sup> and <sup>i</sup> D Nx, we apply the boundary conditions <sup>u</sup><sup>n</sup>C<sup>1</sup> <sup>i</sup> <sup>D</sup> <sup>0</sup>.

Even though sound reasoning leads up to (2.14), there is still a minor challenge with it that needs to be resolved. Think of the very first computational step to be made. The scheme (2.14) is supposed to start at <sup>n</sup> D <sup>1</sup>, which means that we compute u<sup>2</sup> from u<sup>1</sup> and u<sup>0</sup>. Unfortunately, we do not know the value of u<sup>1</sup>, so how to proceed? A standard procedure in such cases is to apply (2.14) also for <sup>n</sup> D <sup>0</sup>. This immediately seems strange, since it involves <sup>u</sup><sup>1</sup> <sup>i</sup> , which is an undefined quantity outside the time mesh (and the time domain). However, we can use the initial condition (2.13) in combination with (2.14) when <sup>n</sup> D <sup>0</sup> to eliminate u<sup>1</sup> <sup>i</sup> and arrive at a special formula for u<sup>1</sup> i :

$$u\_i^1 = u\_i^0 - \frac{1}{2}C^2 \left( u\_{i+1}^0 - 2u\_i^0 + u\_{i-1}^0 \right) \,. \tag{2.16}$$

Figure 2.2 illustrates how (2.16) connects four instead of five points: u<sup>1</sup> <sup>2</sup>, u<sup>0</sup> <sup>1</sup>, u<sup>0</sup> <sup>2</sup>, and u0 3.

We can now summarize the computational algorithm:


The algorithm essentially consists of moving a finite difference stencil through all the mesh points, which can be seen as an animation in a web page1 or a movie file2.

#### **2.1.6 Sketch of an Implementation**

The algorithm only involves the three most recent time levels, so we need only three arrays for u<sup>n</sup>C<sup>1</sup> <sup>i</sup> , u<sup>n</sup> <sup>i</sup> , and u<sup>n</sup><sup>1</sup> <sup>i</sup> , <sup>i</sup> <sup>D</sup> 0; : : : ; Nx. Storing all the solutions in a two-dimensional array of size .Nx <sup>C</sup> 1/ - .Nt <sup>C</sup> 1/ would be possible in this simple one-dimensional PDE problem, but is normally out of the question in threedimensional (3D) and large two-dimensional (2D) problems. We shall therefore, in all our PDE solving programs, have the unknown in memory at as few time levels as possible.

In a Python implementation of this algorithm, we use the array elements u[i] to store u<sup>n</sup>C<sup>1</sup> <sup>i</sup> , u\_n[i] to store u<sup>n</sup> <sup>i</sup> , and u\_nm1[i] to store u<sup>n</sup><sup>1</sup> <sup>i</sup> .

The following Python snippet realizes the steps in the computational algorithm.

```
# Given mesh points as arrays x and t (x[i], t[n])
dx = x[1] - x[0]
dt = t[1] - t[0]
C = c*dt/dx # Courant number
Nt = len(t)-1
C2 = C**2 # Help variable in the scheme
# Set initial condition u(x,0) = I(x)
for i in range(0, Nx+1):
   u_n[i] = I(x[i])
# Apply special formula for first step, incorporating du/dt=0
for i in range(1, Nx):
   u[i] = u_n[i]-\
          0.5*C**2(u_n[i+1] - 2*u_n[i] + u_n[i-1])
u[0] = 0; u[Nx]=0 # Enforce boundary conditions
# Switch variables before next step
u_nm1[:], u_n[:] = u_n, u
for n in range(1, Nt):
   # Update all inner mesh points at time t[n+1]
   for i in range(1, Nx):
       u[i]=2u_n[i] - u_nm1[i]-\
              C**2(u_n[i+1] - 2*u_n[i] + u_n[i-1])
   # Insert boundary conditions
   u[0] = 0; u[Nx]=0
   # Switch variables before next step
   u_nm1[:], u_n[:] = u_n, u
```
<sup>1</sup> http://tinyurl.com/hbcasmj/book/html/mov-wave/D\_stencil\_gpl/index.html

<sup>2</sup> http://tinyurl.com/gokgkov/mov-wave/D\_stencil\_gpl/movie.ogg

#### **2.2 Verification**

Before implementing the algorithm, it is convenient to add a source term to the PDE (2.1), since that gives us more freedom in finding test problems for verification. Physically, a source term acts as a generator for waves in the interior of the domain.

#### **2.2.1 A Slightly Generalized Model Problem**

We now address the following extended initial-boundary value problem for onedimensional wave phenomena:

$$u\_{ll} = c^2 u\_{xx} + f(\mathbf{x}, t), \qquad \qquad \mathbf{x} \in (0, L), \ t \in (0, T] \tag{2.17}$$

$$u(\mathbf{x},0) = I(\mathbf{x}), \quad \text{and} \quad \mathbf{x} \in [0,L] \tag{2.18}$$

$$\mu\_I(\mathbf{x}, \mathbf{0}) = V(\mathbf{x}), \quad \text{and} \quad \mathbf{x} \in [0, L] \tag{2.19}$$

u.0; t/ D 0; t > 0 (2.20)

$$u(L,t) = 0, \qquad \qquad t>0. \tag{2.21}$$

Sampling the PDE at .xi ; tn/ and using the same finite difference approximations as above, yields

$$\left[D\_l D\_l u = c^2 D\_x D\_x u + f\right]\_l^n \,. \tag{2.22}$$

Writing this out and solving for the unknown u<sup>n</sup>C<sup>1</sup> <sup>i</sup> results in

$$u\_{i}^{n+1} = -u\_{i}^{n-1} + 2u\_{i}^{n} + \mathcal{C}^{2}(u\_{i+1}^{n} - 2u\_{i}^{n} + u\_{i-1}^{n}) + \Delta t^{2}f\_{i}^{n}.\tag{2.23}$$

The equation for the first time step must be rederived. The discretization of the initial condition ut <sup>D</sup> V .x/ at <sup>t</sup> <sup>D</sup> <sup>0</sup> becomes

$$\left[D\_{2l}u = V\right]\_i^0 \quad \Rightarrow \quad u\_i^{-1} = u\_i^1 - 2\Delta t \, V\_i,\,\, 0$$

which, when inserted in (2.23) for <sup>n</sup> D <sup>0</sup>, gives the special formula

$$u\_i^1 = u\_i^0 - \Delta t V\_i + \frac{1}{2}C^2 \left( u\_{i+1}^0 - 2u\_i^0 + u\_{i-1}^0 \right) + \frac{1}{2} \Delta t^2 f\_i^0. \tag{2.24}$$

#### **2.2.2 Using an Analytical Solution of Physical Significance**

Many wave problems feature sinusoidal oscillations in time and space. For example, the original PDE problem (2.1)–(2.5) allows an exact solution

$$u\_{\mathbf{c}}(\mathbf{x},t) = A \sin\left(\frac{\pi}{L}\mathbf{x}\right) \cos\left(\frac{\pi}{L}ct\right) \,. \tag{2.25}$$

This <sup>u</sup><sup>e</sup> fulfills the PDE with <sup>f</sup> <sup>D</sup> <sup>0</sup>, boundary conditions <sup>u</sup>e.0; t/ <sup>D</sup> <sup>u</sup>e.L; t/ <sup>D</sup> <sup>0</sup>, as well as initial conditions I.x/ D <sup>A</sup> sin L x and <sup>V</sup> D <sup>0</sup>.

#### **How to use exact solutions for verification**

It is common to use such exact solutions of physical interest to verify implementations. However, the numerical solution u<sup>n</sup> <sup>i</sup> will only be an approximation to ue.xi ; tn/. We have no knowledge of the precise size of the error in this approximation, and therefore we can never know if discrepancies between u<sup>n</sup> <sup>i</sup> and ue.xi ; tn/ are caused by mathematical approximations or programming errors. In particular, if plots of the computed solution u<sup>n</sup> <sup>i</sup> and the exact one (2.25) look similar, many are tempted to claim that the implementation works. However, even if color plots look nice and the accuracy is "deemed good", there can still be serious programming errors present!

The only way to use exact physical solutions like (2.25) for serious and thorough verification is to run a series of simulations on finer and finer meshes, measure the integrated error in each mesh, and from this information estimate the empirical convergence rate of the method.

An introduction to the computing of convergence rates is given in Section 3.1.6 in [9]. There is also a detailed example on computing convergence rates in Sect. 1.2.2.

In the present problem, one expects the method to have a convergence rate of 2 (see Sect. 2.10), so if the computed rates are close to 2 on a sufficiently fine mesh, we have good evidence that the implementation is free of programming mistakes.

#### **2.2.3 Manufactured Solution and Estimation of Convergence Rates**

**Specifying the solution and computing corresponding data** One problem with the exact solution (2.25) is that it requires a simplification (<sup>V</sup> D 0; f D <sup>0</sup>) of the implemented problem (2.17)–(2.21). An advantage of using a *manufactured solution* is that we can test all terms in the PDE problem. The idea of this approach is to set up some chosen solution and fit the source term, boundary conditions, and initial conditions to be compatible with the chosen solution. Given that our boundary conditions in the implementation are u.0; t/ D u.L; t/ D <sup>0</sup>, we must choose a solution that fulfills these conditions. One example is

$$u\_{\mathbf{e}}(\mathbf{x},t) = \mathbf{x}(L-\mathbf{x})\sin t$$

Inserted in the PDE utt <sup>D</sup> <sup>c</sup>2uxx <sup>C</sup> <sup>f</sup> we get

$$-\mathbf{x}(L-\mathbf{x})\sin t = -c^2 2\sin t + f \quad \Rightarrow f = (2c^2 - \mathbf{x}(L-\mathbf{x}))\sin t$$

The initial conditions become

$$\begin{aligned} u(\mathbf{x},0) &= I(\mathbf{x}) = \mathbf{0}, \\ u\_t(\mathbf{x},0) &= V(\mathbf{x}) = \mathbf{x}(L-\mathbf{x}) \dots \end{aligned}$$

**Defining a single discretization parameter** To verify the code, we compute the convergence rates in a series of simulations, letting each simulation use a finer mesh than the previous one. Such empirical estimation of convergence rates relies on an assumption that some measure E of the numerical error is related to the discretization parameters through

$$E = C\_l \Delta t^r + C\_x \Delta x^p,$$

where Ct , Cx, r, and p are constants. The constants r and p are known as the *convergence rates* in time and space, respectively. From the accuracy in the finite difference approximations, we expect <sup>r</sup> D <sup>p</sup> D <sup>2</sup>, since the error terms are of order t <sup>2</sup> and x<sup>2</sup>. This is confirmed by truncation error analysis and other types of analysis.

By using an exact solution of the PDE problem, we will next compute the error measure <sup>E</sup> on a sequence of refined meshes and see if the rates <sup>r</sup> D <sup>p</sup> D <sup>2</sup> are obtained. We will not be concerned with estimating the constants Ct and Cx, simply because we are not interested in their values.

It is advantageous to introduce a single discretization parameter <sup>h</sup> D t D Ocx for some constant <sup>c</sup>O. Since t and x are related through the Courant number, t D Cx=c, we set <sup>h</sup> D t, and then x D hc=C. Now the expression for the error measure is greatly simplified:

$$E = C\_l \Delta t^r + C\_x \Delta x^r = C\_l h^r + C\_x \left(\frac{c}{C}\right)^r h^r = D h^r, \quad D = C\_l + C\_x \left(\frac{c}{C}\right)^r.$$

**Computing errors** We choose an initial discretization parameter h0 and run experiments with decreasing <sup>h</sup>: hi <sup>D</sup> <sup>2</sup><sup>i</sup> h0, <sup>i</sup> D 1; 2; : : : ; m. Halving <sup>h</sup> in each experiment is not necessary, but it is a common choice. For each experiment we must record E and h. Standard choices of error measure are the `2 and `<sup>1</sup> norms of the error mesh function e<sup>n</sup> i :

$$E = ||e\_i^n||\_{\ell^2} = \left(\Delta t \Delta x \sum\_{n=0}^{N\_l} \sum\_{i=0}^{N\_k} (e\_i^n)^2\right)^{\frac{1}{2}}, \quad e\_i^n = u\_\mathfrak{C}(\mathbf{x}\_i, t\_n) - u\_i^n,\tag{2.26}$$

$$E = ||e\_i^n||\_{\ell^\infty} = \max\_{i,n} |e\_i^n|\,. \tag{2.27}$$

In Python, one can compute P i.e<sup>n</sup> <sup>i</sup> /2 at each time step and accumulate the value in some sum variable, say e2\_sum. At the final time step one can do sqrt(dt\*dx\*e2\_sum). For the `<sup>1</sup> norm one must compare the maximum error at a time level (e.max()) with the global maximum over the time domain: e\_max = max(e\_max, e.max()).

An alternative error measure is to use a spatial norm at one time step only, e.g., the end time <sup>T</sup> (<sup>n</sup> D Nt):

$$E = ||e\_i^n||\_{\ell^2} = \left(\Delta x \sum\_{i=0}^{N\_x} (e\_i^n)^2\right)^{\frac{1}{2}}, \quad e\_i^n = u\_\mathfrak{C}(\mathbf{x}\_i, t\_n) - u\_i^n,\tag{2.28}$$

$$E = ||e\_i^n||\_{\ell^\infty} = \max\_{0 \le i \le N\_\chi} |e\_i^n|\,. \tag{2.29}$$

The important point is that the error measure (E) for the simulation is represented by a single number.

**Computing rates** Let Ei be the error measure in experiment (mesh) number i (not to be confused with the spatial index i) and let hi be the corresponding discretization parameter (h). With the error model Ei <sup>D</sup> Dhr <sup>i</sup> , we can estimate r by comparing two consecutive experiments:

$$\begin{aligned} E\_{i+1} &= Dh\_{i+1}^r, \\ E\_i &= Dh\_i^r. \end{aligned}$$

Dividing the two equations eliminates the (uninteresting) constant D. Thereafter, solving for r yields

$$r = \frac{\ln E\_{i+1}/E\_i}{\ln h\_{i+1}/h\_i} \dots$$

Since r depends on i, i.e., which simulations we compare, we add an index to r: ri , where <sup>i</sup> <sup>D</sup> 0; : : : ; m 2, if we have <sup>m</sup> experiments: .h0; E0/; : : : ; .hm1; Em1/.

In our present discretization of the wave equation we expect <sup>r</sup> D <sup>2</sup>, and hence the ri values should converge to 2 as i increases.

#### **2.2.4 Constructing an Exact Solution of the Discrete Equations**

With a manufactured or known analytical solution, as outlined above, we can estimate convergence rates and see if they have the correct asymptotic behavior. Experience shows that this is a quite good verification technique in that many common bugs will destroy the convergence rates. A significantly better test though, would be to check that the numerical solution is exactly what it should be. This will in general require exact knowledge of the numerical error, which we do not normally have (although we in Sect. 2.10 establish such knowledge in simple cases). However, it is possible to look for solutions where we can show that the numerical error vanishes, i.e., the solution of the original continuous PDE problem is also a solution of the discrete equations. This property often arises if the exact solution of the PDE is a lower-order polynomial. (Truncation error analysis leads to error measures that involve derivatives of the exact solution. In the present problem, the truncation error involves 4th-order derivatives of u in space and time. Choosing u as a polynomial of degree three or less will therefore lead to vanishing error.)

We shall now illustrate the construction of an exact solution to both the PDE itself and the discrete equations. Our chosen manufactured solution is quadratic in space and linear in time. More specifically, we set

$$u\_{\mathbf{c}}(\mathbf{x},t) = \mathbf{x}(L-\mathbf{x})\left(\mathbf{l} + \frac{1}{2}t\right),\tag{2.30}$$

which by insertion in the PDE leads to f .x; t/ <sup>D</sup> 2.1 <sup>C</sup> t/c<sup>2</sup>. This <sup>u</sup><sup>e</sup> fulfills the boundary conditions <sup>u</sup> D <sup>0</sup> and demands I.x/ D x.Lx/ and V .x/ D <sup>1</sup> <sup>2</sup>x.Lx/.

To realize that the chosen ue is also an exact solution of the discrete equations, we first remind ourselves that tn <sup>D</sup> nt so that

$$\left[\left[D\_{l}D\_{l}t^{2}\right]^{n}\right] = \frac{t\_{n+1}^{2} - 2t\_{n}^{2} + t\_{n-1}^{2}}{\Delta t^{2}} = (n+1)^{2} - 2n^{2} + (n-1)^{2} = 2,\qquad(2.31)$$

$$[D\_l D\_l t]^n = \frac{t\_{n+1} - 2t\_n + t\_{n-1}}{\Delta t^2} = \frac{((n+1) - 2n + (n-1))\Delta t}{\Delta t^2} = 0. \quad (2.32)$$

Hence,

$$[D\_l D\_l u\_\mathbf{c}]\_l^n = \mathbf{x}\_l (L - \mathbf{x}\_l) \left[ D\_l D\_l \left( 1 + \frac{1}{2} t \right) \right]^n = \mathbf{x}\_l (L - \mathbf{x}\_l) \frac{1}{2} [D\_l D\_l t]^n = 0 \ . \ . $$

Similarly, we get that

$$\begin{aligned} [D\_{\boldsymbol{x}}D\_{\boldsymbol{x}}u\_{\mathfrak{e}}]\_i^n &= \left(1 + \frac{1}{2}t\_n\right)[D\_{\boldsymbol{x}}D\_{\boldsymbol{x}}(\boldsymbol{x}L - \boldsymbol{x}^2)]\_i \\ &= \left(1 + \frac{1}{2}t\_n\right)[LD\_{\boldsymbol{x}}D\_{\boldsymbol{x}}\boldsymbol{x} - D\_{\boldsymbol{x}}D\_{\boldsymbol{x}}\boldsymbol{x}^2]\_i \\ &= -2\left(1 + \frac{1}{2}t\_n\right). \end{aligned}$$

Now, f <sup>n</sup> <sup>i</sup> <sup>D</sup> 2.1 <sup>C</sup> <sup>1</sup> <sup>2</sup> tn/c<sup>2</sup>, which results in

$$\left[D\_t D\_t u\_\mathfrak{C} - c^2 D\_x D\_x u\_\mathfrak{C} - f\right]\_i^n = 0 + c^2 \mathcal{Z}\left(1 + \frac{1}{2}t\_n\right) + \mathcal{Z}\left(1 + \frac{1}{2}t\_n\right) c^2 = 0 \ .$$

Moreover, <sup>u</sup>e.xi ; 0/ <sup>D</sup> I.xi/, @ue=@t <sup>D</sup> V .xi/ at <sup>t</sup> <sup>D</sup> <sup>0</sup>, and <sup>u</sup>e.x0;t/ <sup>D</sup> <sup>u</sup>e.xNx ; 0/ <sup>D</sup> <sup>0</sup>. Also the modified scheme for the first time step is fulfilled by ue.xi ; tn/.

Therefore, the exact solution <sup>u</sup>e.x; t/ D x.Lx/.1Ct=2/ of the PDE problem is also an exact solution of the discrete problem. This means that we know beforehand what numbers the numerical algorithm should produce. We can use this fact to check that the computed u<sup>n</sup> <sup>i</sup> values from an implementation equals ue.xi ; tn/, within machine precision. This result is valid *regardless of the mesh spacings* x and t! Nevertheless, there might be stability restrictions on x and t, so the test can only be run for a mesh that is compatible with the stability criterion (which in the present case is <sup>C</sup> <sup>1</sup>, to be derived later).

#### **Notice**

A product of quadratic or linear expressions in the various independent variables, as shown above, will often fulfill both the PDE problem and the discrete equations, and can therefore be very useful solutions for verifying implementations.

However, for 1D wave equations of the type utt <sup>D</sup> <sup>c</sup>2uxx we shall see that there is always another much more powerful way of generating exact solutions (which consists in just setting <sup>C</sup> D <sup>1</sup> (!), as shown in Sect. 2.10).

#### **2.3 Implementation**

This section presents the complete computational algorithm, its implementation in Python code, animation of the solution, and verification of the implementation.

A real implementation of the basic computational algorithm from Sect. 2.1.5 and 2.1.6 can be encapsulated in a function, taking all the input data for the problem as arguments. The physical input data consists of c, I.x/, V .x/, f .x; t/, L, and T . The numerical input is the mesh parameters t and x.

Instead of specifying t *and* x, we can specify one of them and the Courant number C instead, since having explicit control of the Courant number is convenient when investigating the numerical method. Many find it natural to prescribe the resolution of the spatial grid and set Nx. The solver function can then compute t <sup>D</sup> CL=.cNx/. However, for comparing u.x; t/ curves (as functions of <sup>x</sup>) for various Courant numbers it is more convenient to keep t fixed for all C and let x vary according to x D ct=C. With t fixed, all frames correspond to the same time t, and this simplifies animations that compare simulations with different mesh resolutions. Plotting functions of x with different spatial resolution is trivial, so it is easier to let x vary in the simulations than t.

#### **2.3.1 Callback Function for User-Specific Actions**

The solution at all spatial points at a new time level is stored in an array u of length Nx <sup>C</sup> <sup>1</sup>. We need to decide what to do with this solution, e.g., visualize the curve, analyze the values, or write the array to file for later use. The decision about what to do is left to the user in the form of a user-supplied function

```
user_action(u, x, t, n)
```
where u is the solution at the spatial points x at time t[n]. The user\_action function is called from the solver at each time level n.

If the user wants to plot the solution or store the solution at a time point, she needs to write such a function and take appropriate actions inside it. We will show examples on many such user\_action functions.

Since the solver function makes calls back to the user's code via such a function, this type of function is called a *callback function*. When writing general software, like our solver function, which also needs to carry out special problem- or solution-dependent actions (like visualization), it is a common technique to leave those actions to user-supplied callback functions.

The callback function can be used to terminate the solution process if the user returns True. For example,

```
def my_user_action_function(u, x, t, n):
    return np.abs(u).max() > 10
```
is a callback function that will terminate the solver function (given below) of the amplitude of the waves exceed 10, which is here considered as a numerical instability.

#### **2.3.2 The Solver Function**

A first attempt at a solver function is listed below.

```
import numpy as np
def solver(I, V, f, c, L, dt, C, T, user_action=None):
   """Solve u_tt=c^2*u_xx + f on (0,L)x(0,T]."""
   Nt = int(round(T/dt))
   t = np.linspace(0, Nt*dt, Nt+1) # Mesh points in time
   dx = dt*c/float(C)
   Nx = int(round(L/dx))
   x = np.linspace(0, L, Nx+1) # Mesh points in space
   C2 = C**2 # Help variable in the scheme
   # Make sure dx and dt are compatible with x and t
   dx = x[1] - x[0]
   dt = t[1] - t[0]
   if f is None or f == 0 :
       f = lambda x, t: 0
   if V is None or V == 0:
       V = lambda x: 0
   u = np.zeros(Nx+1) # Solution array at new time level
   u_n = np.zeros(Nx+1) # Solution at 1 time level back
   u_nm1 = np.zeros(Nx+1) # Solution at 2 time levels back
   import time; t0 = time.clock() # Measure CPU time
   # Load initial condition into u_n
   for i in range(0,Nx+1):
       u_n[i] = I(x[i])
   if user_action is not None:
       user_action(u_n, x, t, 0)
   # Special formula for first time step
   n = 0
   for i in range(1, Nx):
       u[i] = u_n[i] + dt*V(x[i]) + \
              0.5*C2*(u_n[i-1] - 2*u_n[i] + u_n[i+1]) + \
              0.5*dt**2*f(x[i], t[n])
   u[0] = 0; u[Nx]=0
   if user_action is not None:
       user_action(u, x, t, 1)
   # Switch variables before next step
   u_nm1[:] = u_n; u_n[:] = u
```

```
for n in range(1, Nt):
    # Update all inner points at time t[n+1]
    for i in range(1, Nx):
        u[i]=- u_nm1[i] + 2*u_n[i]+\
                 C2*(u_n[i-1] - 2*u_n[i] + u_n[i+1]) + \
                 dt**2*f(x[i], t[n])
    # Insert boundary conditions
    u[0] = 0; u[Nx]=0
    if user_action is not None:
        if user_action(u, x, t, n+1):
            break
    # Switch variables before next step
    u_nm1[:] = u_n; u_n[:] = u
cpu_time = time.clock() - t0
return u, x, t, cpu_time
```
A couple of remarks about the above code is perhaps necessary:


#### **2.3.3 Verification: Exact Quadratic Solution**

We use the test problem derived in Sect. 2.2.1 for verification. Below is a unit test based on this test problem and realized as a proper *test function* compatible with the unit test frameworks nose or pytest.

```
def test_quadratic():
    """Check that u(x,t)=x(L-x)(1+t/2) is exactly reproduced."""
    def u_exact(x, t):
        return x*(L-x)*(1 + 0.5*t)
    def I(x):
        return u_exact(x, 0)
    def V(x):
        return 0.5*u_exact(x, 0)
    def f(x, t):
        return 2*(1 + 0.5*t)*c**2
```

```
L = 2.5
c = 1.5
C = 0.75
Nx = 6 # Very coarse mesh for this exact test
dt = C*(L/Nx)/c
T = 18
def assert_no_error(u, x, t, n):
    u_e = u_exact(x, t[n])
    diff = np.abs(u - u_e).max()
    tol = 1E-13
    assert diff < tol
solver(I, V, f, c, L, dt, C, T,
       user_action=assert_no_error)
```
When this function resides in the file wave1D\_u0.py, one can run pytest to check that all test functions with names test\_\*() in this file work:

Terminal Terminal> py.test -s -v wave1D\_u0.py

#### **2.3.4 Verification: Convergence Rates**

A more general method, but not so reliable as a verification method, is to compute the convergence rates and see if they coincide with theoretical estimates. Here we expect a rate of 2 according to the various results in Sect. 2.10. A general function for computing convergence rates can be written like this:

```
def convergence_rates(
   u_exact, # Python function for exact solution
   I, V, f, c, L, # physical parameters
   dt0, num_meshes, C, T): # numerical parameters
   """
   Half the time step and estimate convergence rates for
   for num_meshes simulations.
   """
   # First define an appropriate user action function
   global error
   error = 0 # error computed in the user action function
   def compute_error(u, x, t, n):
       global error # must be global to be altered here
       # (otherwise error is a local variable, different
       # from error defined in the parent function)
       if n == 0:
           error = 0
       else:
           error = max(error, np.abs(u - u_exact(x, t[n])).max())
```

```
# Run finer and finer resolutions and compute true errors
E = []
h = [] # dt, solver adjusts dx such that C=dt*c/dx
dt = dt0
for i in range(num_meshes):
    solver(I, V, f, c, L, dt, C, T,
           user_action=compute_error)
    # error is computed in the final call to compute_error
    E.append(error)
    h.append(dt)
    dt /= 2 # halve the time step for next simulation
print 'E:', E
print 'h:', h
# Convergence rates for two consecutive experiments
r = [np.log(E[i]/E[i-1])/np.log(h[i]/h[i-1])
     for i in range(1,num_meshes)]
return r
```
Using the analytical solution from Sect. 2.2.2, we can call convergence\_rates to see if we get a convergence rate that approaches 2 and use the final estimate of the rate in an assert statement such that this function becomes a proper test function:

```
def test_convrate_sincos():
    n = m = 2
    L = 1.0
    u_exact = lambda x, t: np.cos(m*np.pi/L*t)*np.sin(m*np.pi/L*x)
    r = convergence_rates(
        u_exact=u_exact,
        I=lambda x: u_exact(x, 0),
        V=lambda x: 0,
        f=0,
        c=1,
        L=L,
        dt0=0.1,
        num_meshes=6,
        C=0.9,
        T=1)
    print 'rates sin(x)*cos(t) solution:', \
          [round(r_,2) for r_ in r]
    assert abs(r[-1] - 2) < 0.002
```
Doing py.test -s -v wave1D\_u0.py will run also this test function and show the rates 2.05, 1.98, 2.00, 2.00, and 2.00 (to two decimals).

#### **2.3.5 Visualization: Animating the Solution**

Now that we have verified the implementation it is time to do a real computation where we also display evolution of the waves on the screen. Since the solver function knows nothing about what type of visualizations we may want, it calls the callback function user\_action(u, x, t, n). We must therefore write this function and find the proper statements for plotting the solution.

#### **Function for administering the simulation** The following viz function


```
def viz(
    I, V, f, c, L, dt, C, T, # PDE parameters
    umin, umax, # Interval for u in plots
    animate=True, # Simulation with animation?
    tool='matplotlib', # 'matplotlib' or 'scitools'
    solver_function=solver, # Function with numerical algorithm
    ):
    """Run solver and visualize u at each time level."""
    def plot_u_st(u, x, t, n):
        """user_action function for solver."""
        plt.plot(x, u, 'r-',
                 xlabel='x', ylabel='u',
                 axis=[0, L, umin, umax],
                 title='t=%f' % t[n], show=True)
        # Let the initial condition stay on the screen for 2
        # seconds, else insert a pause of 0.2 s between each plot
        time.sleep(2) if t[n] == 0 else time.sleep(0.2)
        plt.savefig('frame_%04d.png' % n) # for movie making
    class PlotMatplotlib:
        def __call__(self, u, x, t, n):
            """user_action function for solver."""
            if n == 0:
                plt.ion()
                self.lines = plt.plot(x, u, 'r-')
                plt.xlabel('x'); plt.ylabel('u')
                plt.axis([0, L, umin, umax])
                plt.legend(['t=%f' % t[n]], loc='lower left')
            else:
                self.lines[0].set_ydata(u)
                plt.legend(['t=%f' % t[n]], loc='lower left')
                plt.draw()
            time.sleep(2) if t[n] == 0 else time.sleep(0.2)
            plt.savefig('tmp_%04d.png' % n) # for movie making
    if tool == 'matplotlib':
        import matplotlib.pyplot as plt
        plot_u = PlotMatplotlib()
    elif tool == 'scitools':
        import scitools.std as plt # scitools.easyviz interface
        plot_u = plot_u_st
    import time, glob, os
    # Clean up old movie frames
    for filename in glob.glob('tmp_*.png'):
        os.remove(filename)
```

```
# Call solver and do the simulation
user_action = plot_u if animate else None
u, x, t, cpu = solver_function(
    I, V, f, c, L, dt, C, T, user_action)
# Make video files
fps = 4 # frames per second
codec2ext = dict(flv='flv', libx264='mp4', libvpx='webm',
                 libtheora='ogg') # video formats
filespec = 'tmp_%04d.png'
movie_program = 'ffmpeg' # or 'avconv'
for codec in codec2ext:
    ext = codec2ext[codec]
    cmd = '%(movie_program)s -r %(fps)d -i %(filespec)s '\
          '-vcodec %(codec)s movie.%(ext)s' % vars()
    os.system(cmd)
if tool == 'scitools':
    # Make an HTML play for showing the animation in a browser
    plt.movie('tmp_*.png', encoder='html', fps=fps,
              output_file='movie.html')
return cpu
```
**Dissection of the code** The viz function can either use SciTools or Matplotlib for visualizing the solution. The user\_action function based on SciTools is called plot\_u\_st, while the user\_action function based on Matplotlib is a bit more complicated as it is realized as a class and needs statements that differ from those for making static plots. SciTools can utilize both Matplotlib and Gnuplot (and many other plotting programs) for doing the graphics, but Gnuplot is a relevant choice for large Nx or in two-dimensional problems as Gnuplot is significantly faster than Matplotlib for screen animations.

A function inside another function, like plot\_u\_st in the above code segment, has access to *and remembers* all the local variables in the surrounding code inside the viz function (!). This is known in computer science as a *closure* and is very convenient to program with. For example, the plt and time modules defined outside plot\_u are accessible for plot\_u\_st when the function is called (as user\_action) in the solver function. Some may think, however, that a class instead of a closure is a cleaner and easier-to-understand implementation of the user action function, see Sect. 2.8.

The plot\_u\_st function just makes a standard SciTools plot command for plotting u as a function of x at time t[n]. To achieve a smooth animation, the plot command should take keyword arguments instead of being broken into separate calls to xlabel, ylabel, axis, time, and show. Several plot calls will automatically cause an animation on the screen. In addition, we want to save each frame in the animation to file. We then need a filename where the frame number is padded with zeros, here tmp\_0000.png, tmp\_0001.png, and so on. The proper printf construction is then tmp\_%04d.png. Section 1.3.2 contains more basic information on making animations.

The solver is called with an argument plot\_u as user\_function. If the user chooses to use SciTools, plot\_u is the plot\_u\_st callback function, but for Matplotlib it is an instance of the class PlotMatplotlib. Also this class makes use of variables defined in the viz function: plt and time. With Matplotlib, one has to make the first plot the standard way, and then update the y data in the plot at every time level. The update requires active use of the returned value from plt.plot in the first plot. This value would need to be stored in a local variable if we were to use a closure for the user\_action function when doing the animation with Matplotlib. It is much easier to store the variable as a class attribute self.lines. Since the class is essentially a function, we implement the function as the special method \_\_call\_\_ such that the instance plot\_u(u, x, t, n) can be called as a standard callback function from solver.

**Making movie files** From the frame\_\*.png files containing the frames in the animation we can make video files. Section 1.3.2 presents basic information on how to use the ffmpeg (or avconv) program for producing video files in different modern formats: Flash, MP4, Webm, and Ogg.

The viz function creates an ffmpeg or avconv command with the proper arguments for each of the formats Flash, MP4, WebM, and Ogg. The task is greatly simplified by having a codec2ext dictionary for mapping video codec names to filename extensions. As mentioned in Sect. 1.3.2, only two formats are actually needed to ensure that all browsers can successfully play the video: MP4 and WebM.

Some animations having a large number of plot files may not be properly combined into a video using ffmpeg or avconv. A method that always works is to play the PNG files as an animation in a browser using JavaScript code in an HTML file. The SciTools package has a function movie (or a stand-alone command scitools movie) for creating such an HTML player. The plt.movie call in the viz function shows how the function is used. The file movie.html can be loaded into a browser and features a user interface where the speed of the animation can be controlled. Note that the movie in this case consists of the movie.html file and all the frame files tmp\_\*.png.

**Skipping frames for animation speed** Sometimes the time step is small and T is large, leading to an inconveniently large number of plot files and a slow animation on the screen. The solution to such a problem is to decide on a total number of frames in the animation, num\_frames, and plot the solution only for every skip\_frame frames. For example, setting skip\_frame=5 leads to plots of every 5 frames. The default value skip\_frame=1 plots every frame. The total number of time levels (i.e., maximum possible number of frames) is the length of t, t.size (or len(t)), so if we want num\_frames frames in the animation, we need to plot every t.size/num\_frames frames:

```
skip_frame = int(t.size/float(num_frames))
if n % skip_frame == 0 or n == t.size-1:
    st.plot(x, u, 'r-', ...)
```
The initial condition (n=0) is included by n % skip\_frame == 0, as well as every skip\_frame-th frame. As n % skip\_frame == 0 will very seldom be true for the very final frame, we must also check if n == t.size-1 to get the final frame included.

A simple choice of numbers may illustrate the formulas: say we have 801 frames in total (t.size) and we allow only 60 frames to be plotted. As n then runs from 801 to 0, we need to plot every 801/60 frame, which with integer division yields 13 as skip\_frame. Using the mod function, n % skip\_frame, this operation is zero every time n can be divided by 13 without a remainder. That is, the if test is true when n equals 0; 13; 26; 39; : : :; 780; 801. The associated code is included in the plot\_u function, inside the viz function, in the file wave1D\_u0.py.

#### **2.3.6 Running a Case**

The first demo of our 1D wave equation solver concerns vibrations of a string that is initially deformed to a triangular shape, like when picking a guitar string:

$$I(\mathbf{x}) = \begin{cases} a\mathbf{x}/\mathbf{x}\_0, & \mathbf{x} < \mathbf{x}\_0, \\ a(L-\mathbf{x})/(L-\mathbf{x}\_0), & \text{otherwise} \end{cases}. \tag{2.33}$$

We choose <sup>L</sup> <sup>D</sup> <sup>75</sup> cm, x0 <sup>D</sup> 0:8L, <sup>a</sup> <sup>D</sup> <sup>5</sup> mm, and a time frequency <sup>D</sup> <sup>440</sup> Hz. The relation between the wave speed <sup>c</sup> and is <sup>c</sup> D , where is the wavelength, taken as 2L because the longest wave on the string forms half a wavelength. There is no external force, so <sup>f</sup> D <sup>0</sup> (meaning we can neglect gravity), and the string is at rest initially, implying <sup>V</sup> D <sup>0</sup>.

Regarding numerical parameters, we need to specify a t. Sometimes it is more natural to think of a spatial resolution instead of a time step. A natural semi-coarse spatial resolution in the present problem is Nx <sup>D</sup> <sup>50</sup>. We can then choose the associated t (as required by the viz and solver functions) as the stability limit: t D L=.Nxc/. This is the t to be specified, but notice that if C <1, the actual x computed in solver gets larger than L=Nx: x D ct=C D L=.NxC /. (The reason is that we fix t and adjust x, so if C gets smaller, the code implements this effect in terms of a larger x.)

A function for setting the physical and numerical parameters and calling viz in this application goes as follows:

```
def guitar(C):
    """Triangular wave (pulled guitar string)."""
    L = 0.75
    x0 = 0.8*L
    a = 0.005
    freq = 440
    wavelength = 2*L
    c = freq*wavelength
    omega = 2*pi*freq
    num_periods = 1
    T = 2*pi/omega*num_periods
    # Choose dt the same as the stability limit for Nx=50
    dt = L/50./c
    def I(x):
        return a*x/x0 if x < x0 else a/(L-x0)*(L-x)
    umin = -1.2*a; umax = -umin
    cpu = viz(I, 0, 0, c, L, dt, C, T, umin, umax,
              animate=True, tool='scitools')
```
The associated program has the name wave1D\_u0.py. Run the program and watch the movie of the vibrating string3. The string should ideally consist of straight segments, but these are somewhat wavy due to numerical approximation. Run the case with the wave1D\_u0.py code and <sup>C</sup> D <sup>1</sup> to see the exact solution.

#### **2.3.7 Working with a Scaled PDE Model**

Depending on the model, it may be a substantial job to establish consistent and relevant physical parameter values for a case. The guitar string example illustrates the point. However, by *scaling* the mathematical problem we can often reduce the need to estimate physical parameters dramatically. The scaling technique consists of introducing new independent and dependent variables, with the aim that the absolute values of these lie in Œ0; 1. We introduce the dimensionless variables (details are found in Section 3.1.1 in [11])

$$
\bar{\mathfrak{x}} = \frac{\mathfrak{x}}{L}, \quad \bar{t} = \frac{c}{L}t, \quad \bar{u} = \frac{u}{a}.
$$

Here, L is a typical length scale, e.g., the length of the domain, and a is a typical size of <sup>u</sup>, e.g., determined from the initial condition: <sup>a</sup> <sup>D</sup> max<sup>x</sup> <sup>j</sup>I.x/j.

We get by the chain rule that

$$\frac{\partial u}{\partial t} = \frac{\partial}{\partial \bar{t}} \left( a\bar{u} \right) \frac{d\bar{t}}{dt} = \frac{ac}{L} \frac{\partial \bar{u}}{\partial \bar{t}} \dots$$

Similarly,

$$
\frac{\partial u}{\partial x} = \frac{a}{L} \frac{\partial \bar{u}}{\partial \bar{x}} \dots
$$

$$
\dots \quad \dots \quad \text{DCD}
$$

Inserting the dimensionless variables in the PDE gives, in case <sup>f</sup> D <sup>0</sup>,

$$\frac{a^2c^2}{L^2}\frac{\partial^2\bar{u}}{\partial\bar{t}^2} = \frac{a^2c^2}{L^2}\frac{\partial^2\bar{u}}{\partial\bar{x}^2}.$$

Dropping the bars, we arrive at the scaled PDE

$$\frac{\partial^2 u}{\partial t^2} = \frac{\partial^2 u}{\partial x^2}, \quad x \in (0, 1), \ t \in (0, cT/L), \tag{2.34}$$

which has no parameter c<sup>2</sup> anymore. The initial conditions are scaled as

$$a\bar{u}(\bar{\chi},0) = I(L\bar{\chi})$$

and

$$\frac{a}{L/c} \frac{\partial \bar{u}}{\partial \bar{t}}(\bar{\mathbf{x}}, 0) = V(L\bar{\mathbf{x}}),$$

<sup>3</sup> http://tinyurl.com/hbcasmj/wave/html/mov-wave/guitar\_C0.8/movie.html

resulting in

$$
\bar{u}(\bar{\chi},0) = \frac{I(L\bar{\chi})}{\max\_{\chi}|I(\chi)|}, \quad \frac{\partial \bar{u}}{\partial \bar{t}}(\bar{\chi},0) = \frac{L}{ac}V(L\bar{\chi})\,.
$$

In the common case <sup>V</sup> D <sup>0</sup> we see that there are no physical parameters to be estimated in the PDE model!

If we have a program implemented for the physical wave equation with dimensions, we can obtain the dimensionless, scaled version by setting <sup>c</sup> D <sup>1</sup>. The initial condition of a guitar string, given in (2.33), gets its scaled form by choosing <sup>a</sup> D <sup>1</sup>, <sup>L</sup> <sup>D</sup> <sup>1</sup>, and x0 <sup>2</sup> Œ0; 1. This means that we only need to decide on the x0 value as a fraction of unity, because the scaled problem corresponds to setting all other parameters to unity. In the code we can just set a=c=L=1, x0=0.8, and there is no need to calculate with wavelengths and frequencies to estimate c!

The only non-trivial parameter to estimate in the scaled problem is the final end time of the simulation, or more precisely, how it relates to periods in periodic solutions in time, since we often want to express the end time as a certain number of periods. The period in the dimensionless problem is 2, so the end time can be set to the desired number of periods times 2.

Why the dimensionless period is 2 can be explained by the following reasoning. Suppose that u behaves as cos.!t/ in time in the original problem with dimensions. The corresponding period is then <sup>P</sup> D 2=!, but we need to estimate !. A typical solution of the wave equation is u.x; t/ D <sup>A</sup> cos.kx/ cos.!t/, where <sup>A</sup> is an amplitude and <sup>k</sup> is related to the wave length in space: D 2=k. Both and A will be given by the initial condition I.x/. Inserting this u.x; t/ in the PDE yields !<sup>2</sup> D c2k2, i.e., ! D kc. The period is therefore <sup>P</sup> D 2=.kc/. If the boundary conditions are u.0; t/ D u.L; t/, we need to have kL D n for integer <sup>n</sup>. The period becomes <sup>P</sup> D 2L=nc. The longest period is <sup>P</sup> D 2L=c. The dimensionless period <sup>P</sup><sup>Q</sup> is obtained by dividing <sup>P</sup> by the time scale L=c, which results in <sup>P</sup><sup>Q</sup> D <sup>2</sup>. Shorter waves in the initial condition will have a dimensionless shorter period <sup>P</sup><sup>Q</sup> D 2=n (n>1).

#### **2.4 Vectorization**

The computational algorithm for solving the wave equation visits one mesh point at a time and evaluates a formula for the new value u<sup>n</sup>C<sup>1</sup> <sup>i</sup> at that point. Technically, this is implemented by a loop over array elements in a program. Such loops may run slowly in Python (and similar interpreted languages such as R and MATLAB). One technique for speeding up loops is to perform operations on entire arrays instead of working with one element at a time. This is referred to as *vectorization*, *vector computing*, or *array computing*. Operations on whole arrays are possible if the computations involving each element is independent of each other and therefore can, at least in principle, be performed simultaneously. That is, vectorization not only speeds up the code on serial computers, but also makes it easy to exploit parallel computing. Actually, there are Python tools like Numba4 that can automatically turn vectorized code into parallel code.

<sup>4</sup> http://numba.pydata.org

#### **2.4.1 Operations on Slices of Arrays**

Efficient computing with numpy arrays demands that we avoid loops and compute with entire arrays at once (or at least large portions of them). Consider this calculation of differences di <sup>D</sup> uiC<sup>1</sup> ui :

```
n = u.size
for i in range(0, n-1):
    d[i] = u[i+1] - u[i]
```
All the differences here are independent of each other. The computation of d can therefore alternatively be done by subtracting the array .u0; u1;:::;un1/ from the array where the elements are shifted one index upwards: .u1; u2;:::;un/, see Fig. 2.3. The former subset of the array can be expressed by u[0:n-1], u[0:-1], or just u[:-1], meaning from index 0 up to, but not including, the last element (-1). The latter subset is obtained by u[1:n] or u[1:], meaning from index 1 and the rest of the array. The computation of d can now be done without an explicit Python loop:

d = u[1:] - u[:-1]

or with explicit limits if desired:

d = u[1:n] - u[0:n-1]

Indices with a colon, going from an index to (but not including) another index are called *slices*. With numpy arrays, the computations are still done by loops, but in efficient, compiled, highly optimized C or Fortran code. Such loops are sometimes referred to as *vectorized loops*. Such loops can also easily be distributed among many processors on parallel computers. We say that the *scalar code* above, working on an element (a scalar) at a time, has been replaced by an equivalent *vectorized code*. The process of vectorizing code is called *vectorization*.

#### **Test your understanding**

Newcomers to vectorization are encouraged to choose a small array u, say with five elements, and simulate with pen and paper both the loop version and the vectorized version above.

Finite difference schemes basically contain differences between array elements with shifted indices. As an example, consider the updating formula

```
for i in range(1, n-1):
    u2[i] = u[i-1] - 2*u[i] + u[i+1]
```
The vectorization consists of replacing the loop by arithmetics on slices of arrays of length n-2:

```
u2 = u[:-2] - 2*u[1:-1] + u[2:]
u2 = u[0:n-2] - 2*u[1:n-1] + u[2:n] # alternative
```
Note that the length of u2 becomes n-2. If u2 is already an array of length n and we want to use the formula to update all the "inner" elements of u2, as we will when solving a 1D wave equation, we can write

```
u2[1:-1] = u[:-2] - 2*u[1:-1] + u[2:]
u2[1:n-1] = u[0:n-2] - 2*u[1:n-1] + u[2:n] # alternative
```
The first expression's right-hand side is realized by the following steps, involving temporary arrays with intermediate results, since each array operation can only involve one or two arrays. The numpy package performs (behind the scenes) the first line above in four steps:

```
temp1 = 2*u[1:-1]
temp2 = u[:-2] - temp1
temp3 = temp2 + u[2:]
u2[1:-1] = temp3
```
We need three temporary arrays, but a user does not need to worry about such temporary arrays.

**Common mistakes with array slices**

Array expressions with slices demand that the slices have the same shape. It easy to make a mistake in, e.g.,

u2[1:n-1] = u[0:n-2] - 2\*u[1:n-1] + u[2:n]

and write

u2[1:n-1] = u[0:n-2] - 2\*u[1:n-1] + u[1:n]

Now u[1:n] has wrong length (n-1) compared to the other array slices, causing a ValueError and the message could not broadcast input array from shape 103 into shape 104 (if n is 105). When such errors occur one must closely examine all the slices. Usually, it is easier to get upper limits of slices right when they use -1 or -2 or empty limit rather than expressions involving the length.

Another common mistake, when u2 has length n, is to forget the slice in the array on the left-hand side,

u2 = u[0:n-2] - 2\*u[1:n-1] + u[1:n]

This is really crucial: now u2 becomes a *new* array of length n-2, which is the wrong length as we have no entries for the boundary values. We meant to insert the right-hand side array *into* the original u2 array for the entries that correspond to the internal points in the mesh (1:n-1 or 1:-1).

Vectorization may also work nicely with functions. To illustrate, we may extend the previous example as follows:

```
def f(x):
    return x**2 + 1
for i in range(1, n-1):
    u2[i] = u[i-1] - 2*u[i] + u[i+1] + f(x[i])
```
Assuming u2, u, and x all have length n, the vectorized version becomes

u2[1:-1] = u[:-2] - 2\*u[1:-1] + u[2:] + f(x[1:-1])

Obviously, f must be able to take an array as argument for f(x[1:-1]) to make sense.

#### **2.4.2 Finite Difference Schemes Expressed as Slices**

We now have the necessary tools to vectorize the wave equation algorithm as described mathematically in Sect. 2.1.5 and through code in Sect. 2.3.2. There are three loops: one for the initial condition, one for the first time step, and finally the loop that is repeated for all subsequent time levels. Since only the latter is repeated a potentially large number of times, we limit our vectorization efforts to this loop. Within the time loop, the space loop reads:

```
for i in range(1, Nx):
   u[i] = 2*u_n[i] - u_nm1[i]+\
           C2*(u_n[i-1] - 2*u_n[i] + u_n[i+1])
```
The vectorized version becomes

```
u[1:-1] = - u_nm1[1:-1] + 2*u_n[1:-1] + \
          C2*(u_n[:-2] - 2*u_n[1:-1] + u_n[2:])
```
or

```
u[1:Nx] = 2*u_n[1:Nx]- u_nm1[1:Nx]+\
          C2*(u_n[0:Nx-1] - 2*u_n[1:Nx] + u_n[2:Nx+1])
```
The program wave1D\_u0v.py contains a new version of the function solver where both the scalar and the vectorized loops are included (the argument version is set to scalar or vectorized, respectively).

#### **2.4.3 Verification**

We may reuse the quadratic solution <sup>u</sup>e.x; t/ D x.L x/.1 C <sup>1</sup> <sup>2</sup> t/ for verifying also the vectorized code. A test function can now verify both the scalar and the vectorized version. Moreover, we may use a user\_action function that compares the computed and exact solution at each time level and performs a test:

```
def test_quadratic():
    """
    Check the scalar and vectorized versions for
    a quadratic u(x,t)=x(L-x)(1+t/2) that is exactly reproduced.
    """
    # The following function must work for x as array or scalar
    u_exact = lambda x, t: x*(L - x)*(1 + 0.5*t)
    I = lambda x: u_exact(x, 0)
    V = lambda x: 0.5*u_exact(x, 0)
    # f is a scalar (zeros_like(x) works for scalar x too)
    f = lambda x, t: np.zeros_like(x) + 2*c**2*(1 + 0.5*t)
    L = 2.5
    c = 1.5
    C = 0.75
    Nx = 3 # Very coarse mesh for this exact test
    dt = C*(L/Nx)/c
    T = 18
    def assert_no_error(u, x, t, n):
        u_e = u_exact(x, t[n])
        tol = 1E-13
        diff = np.abs(u - u_e).max()
        assert diff < tol
    solver(I, V, f, c, L, dt, C, T,
           user_action=assert_no_error, version='scalar')
    solver(I, V, f, c, L, dt, C, T,
           user_action=assert_no_error, version='vectorized')
```
#### **Lambda functions**

The code segment above demonstrates how to achieve very compact code, without degraded readability, by use of lambda functions for the various input parameters that require a Python function. In essence,

```
f = lambda x, t: L*(x-t)**2
```
is equivalent to

```
def f(x, t):
    return L(x-t)**2
```
Note that lambda functions can just contain a single expression and no statements.

One advantage with lambda functions is that they can be used directly in calls:

solver(I=lambda x: sin(pi\*x/L), V=0, f=0, ...)

#### **2.4.4 Efficiency Measurements**

The wave1D\_u0v.py contains our new solver function with both scalar and vectorized code. For comparing the efficiency of scalar versus vectorized code, we need a viz function as discussed in Sect. 2.3.5. All of this viz function can be reused, except the call to solver\_function. This call lacks the parameter version, which we want to set to vectorized and scalar for our efficiency measurements.

One solution is to copy the viz code from wave1D\_u0 into wave1D\_u0v.py and add a version argument to the solver\_function call. Taking into account how much animation code we then duplicate, this is not a good idea. Alternatively, introducing the version argument in wave1D\_u0.viz, so that this function can be imported into wave1D\_u0v.py, is not a good solution either, since version has no meaning in that file. We need better ideas!

**Solution 1** Calling viz in wave1D\_u0 with solver\_function as our new solver in wave1D\_u0v works fine, since this solver has version='vectorized' as default value. The problem arises when we want to test version='scalar'. The simplest solution is then to use wave1D\_u0.solver instead. We make a new viz function in wave1D\_u0v.py that has a version argument and that just calls wave1D\_u0.viz:

```
def viz(
   I, V, f, c, L, dt, C, T, # PDE parameters
   umin, umax, # Interval for u in plots
   animate=True, # Simulation with animation?
   tool='matplotlib', # 'matplotlib' or 'scitools'
   solver_function=solver, # Function with numerical algorithm
   version='vectorized', # 'scalar' or 'vectorized'
   ):
   import wave1D_u0
   if version == 'vectorized':
       # Reuse viz from wave1D_u0, but with the present
       # modules' new vectorized solver (which has
       # version='vectorized' as default argument;
       # wave1D_u0.viz does not feature this argument)
       cpu = wave1D_u0.viz(
           I, V, f, c, L, dt, C, T, umin, umax,
           animate, tool, solver_function=solver)
   elif version == 'scalar':
       # Call wave1D_u0.viz with a solver with
       # scalar code and use wave1D_u0.solver.
       cpu = wave1D_u0.viz(
           I, V, f, c, L, dt, C, T, umin, umax,
           animate, tool,
           solver_function=wave1D_u0.solver)
```
**Solution 2** There is a more advanced and fancier solution featuring a very useful trick: we can make a new function that will always call wave1D\_u0v.solver with version='scalar'. The functools.partial function from standard Python takes a function func as argument and a series of positional and keyword arguments and returns a new function that will call func with the supplied arguments, while the user can control all the other arguments in func. Consider a trivial example,

def f(a, b, c=2): return a + b + c

We want to ensure that f is always called with c=3, i.e., f has only two "free" arguments a and b. This functionality is obtained by

```
import functools
f2 = functools.partial(f, c=3)
print f2(1, 2) # results in 1+2+3=6
```
Now f2 calls f with whatever the user supplies as a and b, but c is always 3. Back to our viz code, we can do

```
import functools
# Call wave1D_u0.solver with version fixed to scalar
scalar_solver = functools.partial(wave1D_u0.solver, version='scalar')
cpu = wave1D_u0.viz(
        I, V, f, c, L, dt, C, T, umin, umax,
        animate, tool, solver_function=scalar_solver)
```
The new scalar\_solver takes the same arguments as wave1D\_u0.scalar and calls wave1D\_u0v.scalar, but always supplies the extra argument version= 'scalar'. When sending this solver\_function to wave1D\_u0.viz, the latter will call wave1D\_u0v.solver with all the I, V, f, etc., arguments we supply, plus version='scalar'.

**Efficiency experiments** We now have a viz function that can call our solver function both in scalar and vectorized mode. The function run\_efficiency\_ experiments in wave1D\_u0v.py performs a set of experiments and reports the CPU time spent in the scalar and vectorized solver for the previous string vibration example with spatial mesh resolutions Nx <sup>D</sup> 50; 100; 200; 400; 800. Running this function reveals that the vectorized code runs substantially faster: the vectorized code runs approximately Nx=10 times as fast as the scalar code!

#### **2.4.5 Remark on the Updating of Arrays**

At the end of each time step we need to update the u\_nm1 and u\_n arrays such that they have the right content for the next time step:

u\_nm1[:] = u\_n u\_n[:] = u

The order here is important: updating u\_n first, makes u\_nm1 equal to u, which is wrong!

The assignment u\_n[:] = u copies the content of the u array into the elements of the u\_n array. Such copying takes time, but that time is negligible compared to the time needed for computing u from the finite difference formula, even when the formula has a vectorized implementation. However, efficiency of program code is a key topic when solving PDEs numerically (particularly when there are two or three space dimensions), so it must be mentioned that there exists a much more efficient way of making the arrays u\_nm1 and u\_n ready for the next time step. The idea is based on *switching references* and explained as follows.

A Python variable is actually a reference to some object (C programmers may think of pointers). Instead of copying data, we can let u\_nm1 refer to the u\_n object and u\_n refer to the u object. This is a very efficient operation (like switching pointers in C). A naive implementation like

u\_nm1 = u\_n u\_n = u

will fail, however, because now u\_nm1 refers to the u\_n object, but then the name u\_n refers to u, so that this u object has two references, u\_n and u, while our third array, originally referred to by u\_nm1, has no more references and is lost. This means that the variables u, u\_n, and u\_nm1 refer to two arrays and not three. Consequently, the computations at the next time level will be messed up, since updating the elements in u will imply updating the elements in u\_n too, thereby destroying the solution at the previous time step.

While u\_nm1 = u\_n is fine, u\_n = u is problematic, so the solution to this problem is to ensure that u points to the u\_nm1 array. This is mathematically wrong, but new correct values will be filled into u at the next time step and make it right.

The correct switch of references is

```
tmp = u_nm1
u_nm1 = u_n
u_n = u
u = tmp
```
We can get rid of the temporary reference tmp by writing

```
u_nm1, u_n, u = u_n, u, u_nm1
```
This switching of references for updating our arrays will be used in later implementations.

#### **Caution**

The update u\_nm1, u\_n, u = u\_n, u, u\_nm1 leaves wrong content in u at the final time step. This means that if we return u, as we do in the example codes here, we actually return u\_nm1, which is obviously wrong. It is therefore important to adjust the content of u to u = u\_n before returning u. (Note that the user\_action function reduces the need to return the solution from the solver.)

#### **2.5 Exercises**

#### **Exercise 2.1: Simulate a standing wave**

The purpose of this exercise is to simulate standing waves on Œ0; L and illustrate the error in the simulation. Standing waves arise from an initial condition

$$u(\mathbf{x},0) = A \sin\left(\frac{\pi}{L}m\mathbf{x}\right),$$

where m is an integer and A is a freely chosen amplitude. The corresponding exact solution can be computed and reads

$$u\_{\mathfrak{C}}(\mathbf{x},t) = A \sin\left(\frac{\pi}{L}m\mathbf{x}\right) \cos\left(\frac{\pi}{L}mct\right) \dots$$


*Hint 1* Quite long time simulations are needed in order to display significant discrepancies between the numerical and exact solution.

*Hint 2* A possible set of parameters is <sup>L</sup> <sup>D</sup> <sup>12</sup>, <sup>m</sup> <sup>D</sup> <sup>9</sup>, <sup>c</sup> <sup>D</sup> <sup>2</sup>, <sup>A</sup> <sup>D</sup> <sup>1</sup>, Nx <sup>D</sup> <sup>80</sup>, <sup>C</sup> D 0:8. The error mesh function <sup>e</sup><sup>n</sup> can be simulated for 10 periods, while 20–30 periods are needed to show significant differences between the curves for the numerical and exact solution.

Filename: wave\_standing.

*Remarks* The important parameters for numerical quality are C and kx, where <sup>C</sup> D ct=x is the Courant number and <sup>k</sup> is defined above (kx is proportional to how many mesh points we have per wave length in space, see Sect. 2.10.4 for explanation).

#### **Exercise 2.2: Add storage of solution in a user action function**

Extend the plot\_u function in the file wave1D\_u0.py to also store the solutions u in a list. To this end, declare all\_u as an empty list in the viz function, outside plot\_u, and perform an append operation inside the plot\_u function. Note that a function, like plot\_u, inside another function, like viz, remembers all local variables in viz function, including all\_u, even when plot\_u is called (as user\_action) in the solver function. Test both all\_u.append(u) and all\_u.append(u.copy()). Why does one of these constructions fail to store the solution correctly? Let the viz function return the all\_u list converted to a two-dimensional numpy array.

Filename: wave1D\_u0\_s\_store.

#### **Exercise 2.3: Use a class for the user action function**

Redo Exercise 2.2 using a class for the user action function. Let the all\_u list be an attribute in this class and implement the user action function as a method (the special method \_\_call\_\_ is a natural choice). The class versions avoid that the user action function depends on parameters defined outside the function (such as all\_u in Exercise 2.2).

Filename: wave1D\_u0\_s2c.

#### **Exercise 2.4: Compare several Courant numbers in one movie**

The goal of this exercise is to make movies where several curves, corresponding to different Courant numbers, are visualized. Write a program that resembles wave1D\_u0\_s2c.py in Exercise 2.3, but with a viz function that can take a list of C values as argument and create a movie with solutions corresponding to the given C values. The plot\_u function must be changed to store the solution in an array (see Exercise 2.2 or 2.3 for details), solver must be computed for each value of the Courant number, and finally one must run through each time step and plot all the spatial solution curves in one figure and store it in a file.

The challenge in such a visualization is to ensure that the curves in one plot correspond to the same time point. The easiest remedy is to keep the time resolution constant and change the space resolution to change the Courant number. Note that each spatial grid is needed for the final plotting, so it is an option to store those grids too.

Filename: wave\_numerics\_comparison.

#### **Exercise 2.5: Implementing the solver function as a generator**

The callback function user\_action(u, x, t, n) is called from the solver function (in, e.g., wave1D\_u0.py) at every time level and lets the user work perform desired actions with the solution, like plotting it on the screen. We have implemented the callback function in the typical way it would have been done in C and Fortran. Specifically, the code looks like

```
if user_action is not None:
    if user_action(u, x, t, n):
        break
```
Many Python programmers, however, may claim that solver is an iterative process, and that iterative processes with callbacks to the user code is more elegantly implemented as *generators*. The rest of the text has little meaning unless you are familiar with Python generators and the yield statement.

Instead of calling user\_action, the solver function issues a yield statement, which is a kind of return statement:

yield u, x, t, n

The program control is directed back to the calling code:

```
for u, x, t, n in solver(...):
    # Do something with u at t[n]
```
When the block is done, solver continues with the statement after yield. Note that the functionality of terminating the solution process if user\_action returns a True value is not possible to implement in the generator case.

Implement the solver function as a generator, and plot the solution at each time step.

Filename: wave1D\_u0\_generator.

#### **Project 2.6: Calculus with 1D mesh functions**

This project explores integration and differentiation of mesh functions, both with scalar and vectorized implementations. We are given a mesh function fi on a spatial one-dimensional mesh xi <sup>D</sup> ix, <sup>i</sup> <sup>D</sup> 0; : : : ; Nx, over the interval Œa; b.


of fi . We then have

$$F\_i = \int\_{x\_0}^{x\_i} f(\mathbf{x})d\mathbf{x} \,\,.$$

The exact integral of a piecewise linear function f .x/ is given by the Trapezoidal rule. Show that if Fi is already computed, we can find FiC<sup>1</sup> from

$$F\_{i+1} = F\_i + \frac{1}{2}(f\_i + f\_{i+1})\Delta x\_{..}$$

Make a function for the scalar implementation of the discrete integral as a mesh function. That is, the function should return Fi for <sup>i</sup> <sup>D</sup> 0; : : : ; Nx. For a unit test one can use the fact that the above defined discrete integral of a linear function (say f .x/ D 4x 2:5) is exact.

d) Vectorize the implementation of the discrete integral. Extend the unit test to check the validity of the implementation.

*Hint* Interpret the recursive formula for FiC<sup>1</sup> as a sum. Make an array with each element of the sum and use the "cumsum" (numpy.cumsum) operation to compute the accumulative sum: numpy.cumsum([1,3,5]) is [1,4,9].

e) Create a class MeshCalculus that can integrate and differentiate mesh functions. The class can just define some methods that call the previously implemented Python functions. Here is an example on the usage:

```
import numpy as np
calc = MeshCalculus(vectorized=True)
x = np.linspace(0, 1, 11) # mesh
f = np.exp(x) # mesh function
df = calc.differentiate(f, x) # discrete derivative
F = calc.integrate(f, x) # discrete anti-derivative
```
Filename: mesh\_calculus\_1D.

#### **2.6 Generalization: Reflecting Boundaries**

The boundary condition <sup>u</sup> D <sup>0</sup> in a wave equation reflects the wave, but <sup>u</sup> changes sign at the boundary, while the condition ux <sup>D</sup> <sup>0</sup> reflects the wave as a mirror and preserves the sign, see a web page5 or a movie file<sup>6</sup> for demonstration.

Our next task is to explain how to implement the boundary condition ux <sup>D</sup> <sup>0</sup>, which is more complicated to express numerically and also to implement than a given value of <sup>u</sup>. We shall present two methods for implementing ux <sup>D</sup> <sup>0</sup> in a finite difference scheme, one based on deriving a modified stencil at the boundary, and another one based on extending the mesh with ghost cells and ghost points.

<sup>5</sup> http://tinyurl.com/hbcasmj/book/html/mov-wave/demo\_BC\_gaussian/index.html

<sup>6</sup> http://tinyurl.com/gokgkov/mov-wave/demo\_BC\_gaussian/movie.flv

#### **2.6.1 Neumann Boundary Condition**

When a wave hits a boundary and is to be reflected back, one applies the condition

$$\frac{\partial u}{\partial n} \equiv \mathfrak{n} \cdot \nabla u = 0 \,. \tag{2.35}$$

The derivative @=@n is in the outward normal direction from a general boundary. For a 1D domain Œ0; L, we have that

$$\left. \frac{\partial}{\partial n} \right|\_{\chi=L} = \left. \frac{\partial}{\partial \boldsymbol{\omega}} \right|\_{\boldsymbol{\omega}=L}, \quad \left. \frac{\partial}{\partial n} \right|\_{\boldsymbol{\omega}=0} = -\left. \frac{\partial}{\partial \boldsymbol{\omega}} \right|\_{\boldsymbol{\omega}=0}.$$

#### **Boundary condition terminology**

Boundary conditions that specify the value of @u=@n (or shorter un) are known as Neumann<sup>7</sup> conditions, while Dirichlet conditions8 refer to specifications of u. When the values are zero (@u=@n D <sup>0</sup> or <sup>u</sup> D <sup>0</sup>) we speak about *homogeneous* Neumann or Dirichlet conditions.

#### **2.6.2 Discretization of Derivatives at the Boundary**

How can we incorporate the condition (2.35) in the finite difference scheme? Since we have used central differences in all the other approximations to derivatives in the scheme, it is tempting to implement (2.35) at <sup>x</sup> <sup>D</sup> <sup>0</sup> and <sup>t</sup> <sup>D</sup> tn by the difference

$$[D\_{2x}u]\_0^n = \frac{u\_{-1}^n - u\_1^n}{2\Delta x} = 0.\tag{2.36}$$

The problem is that u<sup>n</sup> <sup>1</sup> is not a <sup>u</sup> value that is being computed since the point is outside the mesh. However, if we combine (2.36) with the scheme

$$u\_{i}^{n+1} = -u\_{i}^{n-1} + 2u\_{i}^{n} + C^{2} \left( u\_{i+1}^{n} - 2u\_{i}^{n} + u\_{i-1}^{n} \right),\tag{2.37}$$

for <sup>i</sup> D <sup>0</sup>, we can eliminate the fictitious value <sup>u</sup><sup>n</sup> <sup>1</sup>. We see that <sup>u</sup><sup>n</sup> <sup>1</sup> <sup>D</sup> <sup>u</sup><sup>n</sup> <sup>1</sup> from (2.36), which can be used in (2.37) to arrive at a modified scheme for the boundary point u<sup>n</sup>C<sup>1</sup> <sup>0</sup> :

$$u\_{i}^{n+1} = -u\_{i}^{n-1} + 2u\_{i}^{n} + 2C^{2} \left(u\_{i+1}^{n} - u\_{i}^{n}\right), \quad i = 0. \tag{2.38}$$

Figure 2.4 visualizes this equation for computing u<sup>3</sup> <sup>0</sup> in terms of u<sup>2</sup> <sup>0</sup>, u<sup>1</sup> <sup>0</sup>, and u<sup>2</sup> 1. Similarly, (2.35) applied at <sup>x</sup> D <sup>L</sup> is discretized by a central difference

$$\frac{u\_{N\_{\ge}+1}^n - u\_{N\_{\ge}-1}^n}{2\Delta x} = 0 \,. \tag{2.39}$$

<sup>7</sup> http://en.wikipedia.org/wiki/Neumann\_boundary\_condition

<sup>8</sup> http://en.wikipedia.org/wiki/Dirichlet\_conditions

**Fig. 2.4** Modified stencil at a boundary with a Neumann condition

Combined with the scheme for <sup>i</sup> <sup>D</sup> Nx we get a modified scheme for the boundary value u<sup>n</sup>C<sup>1</sup> Nx :

$$u\_i^{n+1} = -u\_i^{n-1} + 2u\_i^n + 2C^2 \left(u\_{i-1}^n - u\_i^n\right), \quad i = N\_x. \tag{2.40}$$

The modification of the scheme at the boundary is also required for the special formula for the first time step. How the stencil moves through the mesh and is modified at the boundary can be illustrated by an animation in a web page9 or a movie file10.

#### **2.6.3 Implementation of Neumann Conditions**

We have seen in the preceding section that the special formulas for the boundary points arise from replacing u<sup>n</sup> <sup>i</sup><sup>1</sup> by <sup>u</sup><sup>n</sup> <sup>i</sup>C<sup>1</sup> when computing <sup>u</sup><sup>n</sup>C<sup>1</sup> <sup>i</sup> from the stencil formula for <sup>i</sup> D <sup>0</sup>. Similarly, we replace <sup>u</sup><sup>n</sup> <sup>i</sup>C<sup>1</sup> by <sup>u</sup><sup>n</sup> <sup>i</sup><sup>1</sup> in the stencil formula for <sup>i</sup> D Nx. This observation can conveniently be used in the coding: we just work with the general stencil formula, but write the code such that it is easy to replace u[i-1] by u[i+1] and vice versa. This is achieved by having the indices i+1 and i-1 as variables ip1 (i plus 1) and im1 (i minus 1), respectively. At the boundary we can easily define im1=i+1 while we use im1=i-1 in the internal parts of the mesh. Here are the details of the implementation (note that the updating formula for u[i] is the general stencil formula):

<sup>9</sup> http://tinyurl.com/hbcasmj/book/html/mov-wave/N\_stencil\_gpl/index.html

<sup>10</sup> http://tinyurl.com/gokgkov/mov-wave/N\_stencil\_gpl/movie.ogg

```
i = 0
ip1 = i+1
im1 = ip1 # i-1 -> i+1
u[i] = u_n[i] + C2*(u_n[im1] - 2*u_n[i] + u_n[ip1])
i = Nx
im1 = i-1
ip1 = im1 # i+1 -> i-1
u[i] = u_n[i] + C2*(u_n[im1] - 2*u_n[i] + u_n[ip1])
```
We can in fact create one loop over both the internal and boundary points and use only one updating formula:

```
for i in range(0, Nx+1):
    ip1 = i+1 if i < Nx else i-1
    im1 = i-1 if i > 0 else i+1
    u[i] = u_n[i] + C2*(u_n[im1] - 2*u_n[i] + u_n[ip1])
```
The program wave1D\_n0.py contains a complete implementation of the 1D wave equation with boundary conditions ux <sup>D</sup> <sup>0</sup> at <sup>x</sup> <sup>D</sup> <sup>0</sup> and <sup>x</sup> <sup>D</sup> <sup>L</sup>.

It would be nice to modify the test\_quadratic test case from the wave1D\_u0.py with Dirichlet conditions, described in Sect. 2.4.3. However, the Neumann conditions require the polynomial variation in the x direction to be of third degree, which causes challenging problems when designing a test where the numerical solution is known exactly. Exercise 2.15 outlines ideas and code for this purpose. The only test in wave1D\_n0.py is to start with a plug wave at rest and see that the initial condition is reached again perfectly after one period of motion, but such a test requires <sup>C</sup> D <sup>1</sup> (so the numerical solution coincides with the exact solution of the PDE, see Sect. 2.10.4).

#### **2.6.4 Index Set Notation**

To improve our mathematical writing and our implementations, it is wise to introduce a special notation for index sets. This means that we write xi , followed by <sup>i</sup> <sup>2</sup> *<sup>I</sup>*x, instead of <sup>i</sup> <sup>D</sup> 0; : : : ; Nx. Obviously, *<sup>I</sup>*<sup>x</sup> must be the index set *<sup>I</sup>*<sup>x</sup> D f0; : : : ; Nxg, but it is often advantageous to have a symbol for this set rather than specifying all its elements (all the time, as we have done up to now). This new notation saves writing and makes specifications of algorithms and their implementation as computer code simpler.

The first index in the set will be denoted *<sup>I</sup>*<sup>0</sup> <sup>x</sup> and the last *<sup>I</sup>*<sup>1</sup> <sup>x</sup> . When we need to skip the first element of the set, we use *I*<sup>C</sup> <sup>x</sup> for the remaining subset *I*C <sup>x</sup> D f1; : : : ; Nxg. Similarly, if the last element is to be dropped, we write *I* <sup>x</sup> D f0; : : : ; Nx <sup>1</sup><sup>g</sup> for the remaining indices. All the indices corresponding to inner grid points are specified by *<sup>I</sup>*<sup>i</sup> <sup>x</sup> D f1; : : : ; Nx <sup>1</sup>g. For the time domain we find it natural to explicitly use 0 as the first index, so we will usually write <sup>n</sup> D <sup>0</sup> and t0 rather than <sup>n</sup> <sup>D</sup> *<sup>I</sup>*<sup>0</sup> <sup>t</sup> . We also avoid notation like x*<sup>I</sup>*<sup>1</sup> <sup>x</sup> and will instead use xi , <sup>i</sup> D *<sup>I</sup>*<sup>1</sup> <sup>x</sup> .


The Python code associated with index sets applies the following conventions:

#### **Why index sets are useful**

An important feature of the index set notation is that it keeps our formulas and code independent of how we count mesh points. For example, the notation <sup>i</sup> <sup>2</sup> *<sup>I</sup>*<sup>x</sup> or <sup>i</sup> D *<sup>I</sup>*<sup>0</sup> <sup>x</sup> remains the same whether *I*<sup>x</sup> is defined as above or as starting at 1, i.e., *<sup>I</sup>*<sup>x</sup> D f1; : : : ; Qg. Similarly, we can in the code define Ix=range(Nx+1) or Ix=range(1,Q), and expressions like Ix[0] and Ix[1:-1] remain correct. One application where the index set notation is convenient is conversion of code from a language where arrays has base index 0 (e.g., Python and C) to languages where the base index is 1 (e.g., MATLAB and Fortran). Another important application is implementation of Neumann conditions via ghost points (see next section).

For the current problem setting in the x;t plane, we work with the index sets

$$\mathcal{I}\_{\mathbf{x}} = \{0, \dots, N\_{\mathbf{x}}\}, \quad \mathcal{I}\_{l} = \{0, \dots, N\_{l}\}, \tag{2.41}$$

defined in Python as

Ix = range(0, Nx+1) It = range(0, Nt+1)

A finite difference scheme can with the index set notation be specified as

$$\begin{aligned} u\_i^{n+1} &= u\_i^n - \frac{1}{2} C^2 \left( u\_{i+1}^n - 2u\_i^n + u\_{i-1}^n \right), \quad i \in \mathcal{I}\_\ge^i, \ n = 0, \\\ u\_i^{n+1} &= -u\_i^{n-1} + 2u\_i^n + C^2 \left( u\_{i+1}^n - 2u\_i^n + u\_{i-1}^n \right), \quad i \in \mathcal{I}\_\ge^i, \ n \in \mathcal{I}\_\ge^i, \\\ u\_i^{n+1} &= 0, \quad i = \mathcal{I}\_\ge^0, \ n \in \mathcal{I}\_\ell^-, \\\ u\_i^{n+1} &= 0, \quad i = \mathcal{I}\_\ge^{-1}, \ n \in \mathcal{I}\_\ell^-. \end{aligned}$$

The corresponding implementation becomes

```
# Initial condition
for i in Ix[1:-1]:
    u[i] = u_n[i] - 0.5*C2*(u_n[i-1] - 2*u_n[i] + u_n[i+1])
```

```
# Time loop
for n in It[1:-1]:
   # Compute internal points
   for i in Ix[1:-1]:
       u[i]=- u_nm1[i] + 2*u_n[i]+\
               C2*(u_n[i-1] - 2*u_n[i] + u_n[i+1])
   # Compute boundary conditions
   i = Ix[0]; u[i]=0
   i = Ix[-1]; u[i]=0
```
#### **Notice**

The program wave1D\_dn.py applies the index set notation and solves the 1D wave equation utt <sup>D</sup> <sup>c</sup>2uxx <sup>C</sup> f .x; t/ with quite general boundary and initial conditions:


The program combines Dirichlet and Neumann conditions, scalar and vectorized implementation of schemes, and the index set notation into one piece of code. A lot of test examples are also included in the program:


#### **2.6.5 Verifying the Implementation of Neumann Conditions**

How can we test that the Neumann conditions are correctly implemented? The solver function in the wave1D\_dn.py program described in the box above accepts Dirichlet or Neumann conditions at <sup>x</sup> D <sup>0</sup> and <sup>x</sup> D <sup>L</sup>. It is tempting to apply a quadratic solution as described in Sect. 2.2.1 and 2.3.3, but it turns out that this solution is no longer an exact solution of the discrete equations if a Neumann condition is implemented on the boundary. A linear solution does not help since we only have homogeneous Neumann conditions in wave1D\_dn.py, and we are consequently left with testing just a constant solution: <sup>u</sup> D const.

```
def test_constant():
    """
    Check the scalar and vectorized versions for
    a constant u(x,t). We simulate in [0, L] and apply
    Neumann and Dirichlet conditions at both ends.
    """
    u_const = 0.45
    u_exact = lambda x, t: u_const
    I = lambda x: u_exact(x, 0)
    V = lambda x: 0
    f = lambda x, t: 0
    def assert_no_error(u, x, t, n):
        u_e = u_exact(x, t[n])
        diff = np.abs(u - u_e).max()
        msg = 'diff=%E, t_%d=%g' % (diff, n, t[n])
        tol = 1E-13
        assert diff < tol, msg
    for U_0 in (None, lambda t: u_const):
        for U_L in (None, lambda t: u_const):
            L = 2.5
            c = 1.5
            C = 0.75
            Nx = 3 # Very coarse mesh for this exact test
            dt = C*(L/Nx)/c
            T = 18 # long time integration
            solver(I, V, f, c, U_0, U_L, L, dt, C, T,
                   user_action=assert_no_error,
                   version='scalar')
            solver(I, V, f, c, U_0, U_L, L, dt, C, T,
                   user_action=assert_no_error,
                   version='vectorized')
            print U_0, U_L
```
The quadratic solution is very useful for testing, but it requires Dirichlet conditions at both ends.

Another test may utilize the fact that the approximation error vanishes when the Courant number is unity. We can, for example, start with a plug profile as initial condition, let this wave split into two plug waves, one in each direction, and check that the two plug waves come back and form the initial condition again after "one period" of the solution process. Neumann conditions can be applied at both ends. A proper test function reads

```
def test_plug():
    """Check that an initial plug is correct back after one period."""
    L = 1.0
    c = 0.5
    dt = (L/10)/c # Nx=10
    I = lambda x: 0 if abs(x-L/2.0) > 0.1 else 1
```

```
u_s, x, t, cpu = solver(
    I=I,
    V=None, f=None, c=0.5, U_0=None, U_L=None, L=L,
    dt=dt, C=1, T=4, user_action=None, version='scalar')
u_v, x, t, cpu = solver(
    I=I,
    V=None, f=None, c=0.5, U_0=None, U_L=None, L=L,
    dt=dt, C=1, T=4, user_action=None, version='vectorized')
tol = 1E-13
diff = abs(u_s - u_v).max()
assert diff < tol
u_0 = np.array([I(x_) for x_ in x])
diff = np.abs(u_s - u_0).max()
assert diff < tol
```
Other tests must rely on an unknown approximation error, so effectively we are left with tests on the convergence rate.

#### **2.6.6 Alternative Implementation via Ghost Cells**

**Idea** Instead of modifying the scheme at the boundary, we can introduce extra points outside the domain such that the fictitious values u<sup>n</sup> <sup>1</sup> and <sup>u</sup><sup>n</sup> NxC<sup>1</sup> are defined in the mesh. Adding the intervals <sup>Œ</sup>x; 0 and ŒL; L C x, known as *ghost cells*, to the mesh gives us all the needed mesh points, corresponding to <sup>i</sup> D 1; 0; : : : ; Nx; Nx <sup>C</sup> <sup>1</sup>. The extra points with <sup>i</sup> D <sup>1</sup> and <sup>i</sup> <sup>D</sup> Nx <sup>C</sup> <sup>1</sup> are known as *ghost points*, and values at these points, u<sup>n</sup> <sup>1</sup> and <sup>u</sup><sup>n</sup> NxC<sup>1</sup>, are called *ghost values*.

The important idea is to ensure that we always have

$$
\mu\_{-1}^n = \mu\_1^n \quad \text{and} \quad \mu\_{N\_\chi+1}^n = \mu\_{N\_\chi-1}^n,
$$

because then the application of the standard scheme at a boundary point <sup>i</sup> D <sup>0</sup> or <sup>i</sup> <sup>D</sup> Nx will be correct and guarantee that the solution is compatible with the boundary condition ux <sup>D</sup> <sup>0</sup>.

Some readers may find it strange to just extend the domain with ghost cells as a general technique, because in some problems there is a completely different medium with different physics and equations right outside of a boundary. Nevertheless, one should view the ghost cell technique as a purely mathematical technique, which is valid in the limit x ! <sup>0</sup> and helps us to implement derivatives.

**Implementation** The u array now needs extra elements corresponding to the ghost points. Two new point values are needed:

u = zeros(Nx+3)

The arrays u\_n and u\_nm1 must be defined accordingly.

Unfortunately, a major indexing problem arises with ghost cells. The reason is that Python indices *must* start at 0 and u[-1] will always mean the last element in u. This fact gives, apparently, a mismatch between the mathematical indices <sup>i</sup> D 1; 0; : : : ; Nx <sup>C</sup> <sup>1</sup> and the Python indices running over <sup>u</sup>: 0,..,Nx+2. One remedy is to change the mathematical indexing of i in the scheme and write

$$
\mu\_i^{n+1} = \dotsb, \quad i = 1, \dots, N\_x + 1,
$$

instead of <sup>i</sup> <sup>D</sup> 0; : : : ; Nx as we have previously used. The ghost points now correspond to <sup>i</sup> <sup>D</sup> <sup>0</sup> and <sup>i</sup> <sup>D</sup> Nx <sup>C</sup> <sup>1</sup>. A better solution is to use the ideas of Sect. 2.6.4: we hide the specific index value in an index set and operate with inner and boundary points using the index set notation.

To this end, we define u with proper length and Ix to be the corresponding indices for the real physical mesh points (1; 2; : : : ; Nx <sup>C</sup> <sup>1</sup>):

u = zeros(Nx+3) Ix = range(1, u.shape[0]-1)

That is, the boundary points have indices Ix[0] and Ix[-1] (as before). We first update the solution at all physical mesh points (i.e., interior points in the mesh):

```
for i in Ix:
   u[i]=- u_nm1[i] + 2*u_n[i]+\
          C2*(u_n[i-1] - 2*u_n[i] + u_n[i+1])
```
The indexing becomes a bit more complicated when we call functions like V(x) and f(x, t), as we must remember that the appropriate x coordinate is given as x[i-Ix[0]]:

```
for i in Ix:
    u[i] = u_n[i] + dt*V(x[i-Ix[0]]) + \
           0.5*C2*(u_n[i-1] - 2*u_n[i] + u_n[i+1]) + \
           0.5*dt2*f(x[i-Ix[0]], t[0])
```
It remains to update the solution at ghost points, i.e., u[0] and u[-1] (or u[Nx+2]). For a boundary condition ux <sup>D</sup> <sup>0</sup>, the ghost value must equal the value at the associated inner mesh point. Computer code makes this statement precise:

i = Ix[0] # x=0 boundary u[i-1] = u[i+1] i = Ix[-1] # x=L boundary u[i+1] = u[i-1]

The physical solution to be plotted is now in u[1:-1], or equivalently u[Ix[0]: Ix[-1]+1], so this slice is the quantity to be returned from a solver function. A complete implementation appears in the program wave1D\_n0\_ghost.py.

#### **Warning**

We have to be careful with how the spatial and temporal mesh points are stored. Say we let x be the physical mesh points,

x = linspace(0, L, Nx+1)

"Standard coding" of the initial condition,

for i in Ix: u\_n[i] = I(x[i])

becomes wrong, since u\_n and x have different lengths and the index i corresponds to two different mesh points. In fact, x[i] corresponds to u[1+i]. A correct implementation is

```
for i in Ix:
    u_n[i] = I(x[i-Ix[0]])
```
Similarly, a source term usually coded as f(x[i], t[n]) is incorrect if x is defined to be the physical points, so x[i] must be replaced by x[i-Ix[0]].

An alternative remedy is to let x also cover the ghost points such that u[i] is the value at x[i].

The ghost cell is only added to the boundary where we have a Neumann condition. Suppose we have a Dirichlet condition at <sup>x</sup> D <sup>L</sup> and a homogeneous Neumann condition at <sup>x</sup> D <sup>0</sup>. One ghost cell <sup>Œ</sup>x; 0 is added to the mesh, so the index set for the physical points becomes <sup>f</sup>1; : : : ; Nx <sup>C</sup> <sup>1</sup>g. A relevant implementation is

```
u = zeros(Nx+2)
Ix = range(1, u.shape[0])
...
for i in Ix[:-1]:
   u[i]=- u_nm1[i] + 2*u_n[i]+\
           C2*(u_n[i-1] - 2*u_n[i] + u_n[i+1]) + \
           dt2*f(x[i-Ix[0]], t[n])
i = Ix[-1]
u[i] = U_0 # set Dirichlet value
i = Ix[0]
u[i-1] = u[i+1] # update ghost value
```
The physical solution to be plotted is now in u[1:] or (as always) u[Ix[0]: Ix[-1]+1].

#### **2.7 Generalization: Variable Wave Velocity**

Our next generalization of the 1D wave equation (2.1) or (2.17) is to allow for a variable wave velocity <sup>c</sup>: <sup>c</sup> D c.x/, usually motivated by wave motion in a domain composed of different physical media. When the media differ in physical properties like density or porosity, the wave velocity c is affected and will depend on the position in space. Figure 2.5 shows a wave propagating in one medium Œ0; 0:7 [ Œ0:9; 1 with wave velocity c1 (left) before it enters a second medium .0:7; 0:9/ with wave velocity c2 (right). When the wave meets the boundary where c jumps from c1 to c2, a part of the wave is reflected back into the first medium (the *reflected* wave), while one part is transmitted through the second medium (the *transmitted* wave).

#### **2.7.1 The Model PDE with a Variable Coefficient**

Instead of working with the squared quantity c2.x/, we shall for notational convenience introduce q.x/ D <sup>c</sup>2.x/. A 1D wave equation with variable wave velocity often takes the form

$$\frac{\partial^2 u}{\partial t^2} = \frac{\partial}{\partial x} \left( q(\mathbf{x}) \frac{\partial u}{\partial \mathbf{x}} \right) + f(\mathbf{x}, t) \,. \tag{2.42}$$

This is the most frequent form of a wave equation with variable wave velocity, but other forms also appear, see Sect. 2.14.1 and equation (2.125).

As usual, we sample (2.42) at a mesh point,

$$
\frac{
\partial^2
}{
\partial t^2
}u(\mathbf{x}\_i, t\_n) = \frac{
\partial
}{
\partial \mathbf{x}
} \left(q(\mathbf{x}\_i) \frac{
\partial
}{
\partial \mathbf{x}
} u(\mathbf{x}\_i, t\_n)\right) + f(\mathbf{x}\_i, t\_n),
$$

where the only new term to discretize is

@ @x - q.xi/ @ @x u.xi ; tn/ D @ @x - q.x/@u @x <sup>n</sup> i :

**Fig. 2.5** *Left:* wave entering another medium; *right:* transmitted and reflected wave

#### **2.7.2 Discretizing the Variable Coefficient**

The principal idea is to first discretize the outer derivative. Define

$$
\phi = q(x) \frac{\partial u}{\partial x},
$$

and use a centered derivative around <sup>x</sup> <sup>D</sup> xi for the derivative of :

$$\left[\frac{\partial \phi}{\partial x}\right]\_i^n \approx \frac{\phi\_{i+\frac{1}{2}} - \phi\_{i-\frac{1}{2}}}{\Delta x} = [D\_x \phi]\_i^n \dots$$

Then discretize

$$\phi\_{i+\frac{1}{2}} = q\_{i+\frac{1}{2}} \left[ \frac{\partial u}{\partial \boldsymbol{\omega}} \right]\_{i+\frac{1}{2}}^n \approx q\_{i+\frac{1}{2}} \frac{u\_{i+1}^n - u\_i^n}{\Delta \boldsymbol{\omega}} = [qD\_{\boldsymbol{\omega}}u]\_{i+\frac{1}{2}}^n \dots$$

Similarly,

$$\phi\_{i-\frac{1}{2}} = q\_{i-\frac{1}{2}} \left[ \frac{\partial u}{\partial \boldsymbol{\omega}} \right]\_{i-\frac{1}{2}}^n \approx q\_{i-\frac{1}{2}} \frac{u\_i^n - u\_{i-1}^n}{\Delta \boldsymbol{\omega}} = [qD\_{\boldsymbol{\omega}}u]\_{i-\frac{1}{2}}^n \dots$$

These intermediate results are now combined to

$$\left[\frac{\partial}{\partial \mathbf{x}} \left( q(\mathbf{x}) \frac{\partial \mathbf{u}}{\partial \mathbf{x}} \right) \right]\_i^n \approx \frac{1}{\Delta x^2} \left( q\_{i + \frac{1}{2}} \left( u\_{i+1}^n - u\_i^n \right) - q\_{i - \frac{1}{2}} \left( u\_i^n - u\_{i-1}^n \right) \right). \tag{2.43}$$

With operator notation we can write the discretization as

$$
\left[\frac{\partial}{\partial \boldsymbol{x}} \left(q(\boldsymbol{x}) \frac{\partial \boldsymbol{u}}{\partial \boldsymbol{x}}\right)\right]\_{\boldsymbol{i}}^{n} \approx [D\_{\boldsymbol{x}}(\overline{\boldsymbol{q}}^{\boldsymbol{x}} D\_{\boldsymbol{x}} \boldsymbol{u})]\_{\boldsymbol{i}}^{n}.\tag{2.44}
$$

#### **Do not use the chain rule on the spatial derivative term!**

Many are tempted to use the chain rule on the term @ @x q.x/ @u @x , but this is not a good idea when discretizing such a term.

The term with a variable coefficient expresses the net flux qux into a small volume (i.e., interval in 1D):

$$\frac{\partial}{\partial \mathbf{x}} \left( q(\mathbf{x}) \frac{\partial \boldsymbol{u}}{\partial \mathbf{x}} \right) \approx \frac{1}{\Delta \boldsymbol{x}} (q(\mathbf{x} + \Delta \boldsymbol{x}) \boldsymbol{u}\_{\boldsymbol{x}}(\mathbf{x} + \Delta \boldsymbol{x}) - q(\mathbf{x}) \boldsymbol{u}\_{\boldsymbol{x}}(\mathbf{x})) \dots$$

Our discretization reflects this principle directly: qux at the right end of the cell minus qux at the left end, because this follows from the formula (2.43) or ŒDx.qDxu/<sup>n</sup> i .

When using the chain rule, we get two terms quxx <sup>C</sup> qxux. The typical discretization is

$$\left[D\_x q D\_x u + D\_{2x} q D\_{2x} u\right]\_l^u,\tag{2.45}$$

Writing this out shows that it is different from ŒDx.qDxu/<sup>n</sup> <sup>i</sup> and lacks the physical interpretation of net flux into a cell. With a smooth and slowly varying q.x/ the differences between the two discretizations are not substantial. However, when q exhibits (potentially large) jumps, ŒDx.qDxu/<sup>n</sup> <sup>i</sup> with harmonic averaging of q yields a better solution than arithmetic averaging or (2.45). In the literature, the discretization ŒDx.qDxu/<sup>n</sup> <sup>i</sup> totally dominates and very few mention the alternative in (2.45).

#### **2.7.3 Computing the Coefficient Between Mesh Points**

If <sup>q</sup> is a known function of <sup>x</sup>, we can easily evaluate qiC <sup>1</sup> <sup>2</sup> simply as q.xi<sup>C</sup> <sup>1</sup> 2 / with xiC <sup>1</sup> <sup>2</sup> <sup>D</sup> xi <sup>C</sup> <sup>1</sup> <sup>2</sup>x. However, in many cases c, and hence q, is only known as a discrete function, often at the mesh points xi . Evaluating q between two mesh points xi and xiC<sup>1</sup> must then be done by *interpolation* techniques, of which three are of particular interest in this context:

$$q\_{i+\frac{1}{2}} \approx \frac{1}{2} \left( q\_i + q\_{i+1} \right) = [\overline{q}^\chi]\_i \qquad\qquad\text{(arithmetic mean)}\tag{2.46}$$

$$\begin{aligned} q\_{i+\frac{1}{2}} &\approx 2\left(\frac{1}{q\_i} + \frac{1}{q\_{i+1}}\right)^{-1} & \text{(harmonic mean)} \\ q\_{i+\frac{1}{2}} &\approx \left(q\_i q\_{i+1}\right)^{1/2} & \text{(geometric mean)} \end{aligned} \tag{2.47}$$

The arithmetic mean in (2.46) is by far the most commonly used averaging technique and is well suited for smooth q.x/ functions. The harmonic mean is often preferred when q.x/ exhibits large jumps (which is typical for geological media). The geometric mean is less used, but popular in discretizations to linearize quadratic nonlinearities (see Sect. 1.10.2 for an example).

With the operator notation from (2.46) we can specify the discretization of the complete variable-coefficient wave equation in a compact way:

$$\left[D\_l D\_l u = D\_x \overline{q}^\chi D\_x u + f\right]\_l^n. \tag{2.49}$$

Strictly speaking, ŒDxqxDxun <sup>i</sup> <sup>D</sup> ŒDx.qxDxu/n i .

From the compact difference notation we immediately see what kind of differences that each term is approximated with. The notation q<sup>x</sup> also specifies that the variable coefficient is approximated by an arithmetic mean, the definition being ŒqxiC <sup>1</sup> <sup>2</sup> <sup>D</sup> .qi <sup>C</sup> qiC1/=2.

Before implementing, it remains to solve (2.49) with respect to u<sup>n</sup>C<sup>1</sup> <sup>i</sup> :

$$\begin{split} u\_{i}^{n+1} &= -u\_{i}^{n-1} + 2u\_{i}^{n} \\ &+ \left(\frac{\Delta t}{\Delta x}\right)^{2} \Big( \frac{1}{2} (q\_{i} + q\_{i+1}) (u\_{i+1}^{n} - u\_{i}^{n}) - \frac{1}{2} (q\_{i} + q\_{i-1}) (u\_{i}^{n} - u\_{i-1}^{n}) \Big) \\ &+ \Delta t^{2} f\_{i}^{n} . \end{split} \tag{2.50}$$

#### **2.7.4 How a Variable Coefficient Affects the Stability**

The stability criterion derived later (Sect. 2.10.3) reads t x=c. If <sup>c</sup> D c.x/, the criterion will depend on the spatial location. We must therefore choose a t that is small enough such that no mesh cell has t > x=c.x/. That is, we must use the largest c value in the criterion:

$$
\Delta t \le \beta \frac{\Delta x}{\max\_{\boldsymbol{x} \in [0, L]} c(\boldsymbol{\chi})} \,. \tag{2.51}
$$

The parameter ˇ is included as a safety factor: in some problems with a significantly varying <sup>c</sup> it turns out that one must choose ˇ<1 to have stable solutions (<sup>ˇ</sup> D 0:9 may act as an all-round value).

A different strategy to handle the stability criterion with variable wave velocity is to use a spatially varying t. While the idea is mathematically attractive at first sight, the implementation quickly becomes very complicated, so we stick to a constant t and a worst case value of c.x/ (with a safety factor ˇ).

#### **2.7.5 Neumann Condition and a Variable Coefficient**

Consider a Neumann condition @u=@x D <sup>0</sup> at <sup>x</sup> D <sup>L</sup> D Nxx, discretized as

$$[D\_{2x}u]\_i^n = \frac{u\_{i+1}^n - u\_{i-1}^n}{2\Delta x} = 0 \quad \Rightarrow \quad u\_{i+1}^n = u\_{i-1}^n,$$

for <sup>i</sup> <sup>D</sup> Nx. Using the scheme (2.50) at the end point <sup>i</sup> <sup>D</sup> Nx with <sup>u</sup><sup>n</sup> <sup>i</sup>C<sup>1</sup> <sup>D</sup> <sup>u</sup><sup>n</sup> i1 results in

$$\begin{split} u\_{i}^{n+1} &= -u\_{i}^{n-1} + 2u\_{i}^{n} \\ &+ \left(\frac{\Delta t}{\Delta x}\right)^{2} \left(q\_{i+\frac{1}{2}}(u\_{i-1}^{n} - u\_{i}^{n}) - q\_{i-\frac{1}{2}}(u\_{i}^{n} - u\_{i-1}^{n})\right) + \Delta t^{2} f\_{i}^{n} \end{split} \tag{2.52}$$

$$=-u\_{i}^{n-1} + 2u\_{i}^{n} + \left(\frac{\Delta t}{\Delta x}\right)^{2} (q\_{i+\frac{1}{2}} + q\_{i-\frac{1}{2}})(u\_{i-1}^{n} - u\_{i}^{n}) + \Delta t^{2} f\_{i}^{n} \quad (2.53)$$

$$\approx -u\_i^{n-1} + 2u\_i^n + \left(\frac{\Delta t}{\Delta x}\right)^2 2q\_i (u\_{i-1}^n - u\_i^n) + \Delta t^2 f\_i^n \,. \tag{2.54}$$

Here we used the approximation

$$\begin{split} q\_{i + \frac{1}{2}} + q\_{i - \frac{1}{2}} &= q\_i + \left( \frac{dq}{dx} \right)\_i \Delta x + \left( \frac{d^2 q}{dx^2} \right)\_i \Delta x^2 + \cdots \\ &+ q\_i - \left( \frac{dq}{dx} \right)\_i \Delta x + \left( \frac{d^2 q}{dx^2} \right)\_i \Delta x^2 + \cdots \\ &= 2q\_i + 2 \left( \frac{d^2 q}{dx^2} \right)\_i \Delta x^2 + \mathcal{O}(\Delta x^4) \\ &\approx 2q\_i \, . \end{split} \tag{2.55}$$

An alternative derivation may apply the arithmetic mean of qn <sup>1</sup> <sup>2</sup> and qn<sup>C</sup> <sup>1</sup> <sup>2</sup> in (2.53), leading to the term

$$\left(q\_i + \frac{1}{2}(q\_{i+1} + q\_{i-1})\right)\left(u\_{i-1}^n - u\_i^n\right)\dots$$

Since <sup>1</sup> <sup>2</sup> .qiC<sup>1</sup> <sup>C</sup> qi1/ <sup>D</sup> qi <sup>C</sup> *<sup>O</sup>*.x2/, we can approximate with 2qi.u<sup>n</sup> <sup>i</sup><sup>1</sup> u<sup>n</sup> <sup>i</sup> / for <sup>i</sup> <sup>D</sup> Nx and get the same term as we did above.

A common technique when implementing @u=@x D <sup>0</sup> boundary conditions, is to assume dq=dx <sup>D</sup> <sup>0</sup> as well. This implies qiC<sup>1</sup> <sup>D</sup> qi<sup>1</sup> and qiC1=2 <sup>D</sup> qi1=2 for <sup>i</sup> D Nx. The implications for the scheme are

$$\begin{split} u\_{i}^{n+1} &= -u\_{i}^{n-1} + 2u\_{i}^{n} \\ &+ \left(\frac{\Delta t}{\Delta x}\right)^{2} \Big( q\_{i + \frac{1}{2}} (u\_{i-1}^{n} - u\_{i}^{n}) - q\_{i - \frac{1}{2}} (u\_{i}^{n} - u\_{i-1}^{n}) \Big) \\ &+ \Delta t^{2} f\_{i}^{n} \\ &+ \cdots \end{split} \tag{2.56}$$

$$=-\boldsymbol{u}\_{i}^{n-1} + 2\boldsymbol{u}\_{i}^{n} + \left(\frac{\Delta t}{\Delta x}\right)^{2} 2\boldsymbol{q}\_{i-\frac{1}{2}}(\boldsymbol{u}\_{i-1}^{n} - \boldsymbol{u}\_{i}^{n}) + \Delta t^{2}f\_{i}^{n}.\tag{2.57}$$

#### **2.7.6 Implementation of Variable Coefficients**

The implementation of the scheme with a variable wave velocity q.x/ D <sup>c</sup>2.x/ may assume that q is available as an array q[i] at the spatial mesh points. The following loop is a straightforward implementation of the scheme (2.50):

```
for i in range(1, Nx):
   u[i]=- u_nm1[i] + 2*u_n[i]+\
           C2*(0.5*(q[i] + q[i+1])*(u_n[i+1] - u_n[i]) - \
               0.5*(q[i] + q[i-1])*(u_n[i] - u_n[i-1])) + \
           dt2*f(x[i], t[n])
```
The coefficient C2 is now defined as (dt/dx)\*\*2, i.e., *not* as the squared Courant number, since the wave velocity is variable and appears inside the parenthesis.

With Neumann conditions ux <sup>D</sup> <sup>0</sup> at the boundary, we need to combine this scheme with the discrete version of the boundary condition, as shown in Sect. 2.7.5. Nevertheless, it would be convenient to reuse the formula for the interior points and just modify the indices ip1=i+1 and im1=i-1 as we did in Sect. 2.6.3. Assuming dq=dx D <sup>0</sup> at the boundaries, we can implement the scheme at the boundary with the following code.

```
i = 0
ip1 = i+1
im1 = ip1
u[i]=- u_nm1[i] + 2*u_n[i]+\
       C2*(0.5*(q[i] + q[ip1])*(u_n[ip1] - u_n[i]) - \
           0.5*(q[i] + q[im1])*(u_n[i] - u_n[im1])) + \
       dt2*f(x[i], t[n])
```
With ghost cells we can just reuse the formula for the interior points also at the boundary, provided that the ghost values of both u and q are correctly updated to ensure ux <sup>D</sup> <sup>0</sup> and qx <sup>D</sup> <sup>0</sup>.

A vectorized version of the scheme with a variable coefficient at internal mesh points becomes

u[1:-1] = - u\_nm1[1:-1] + 2\*u\_n[1:-1] + \ C2\*(0.5\*(q[1:-1] + q[2:])\*(u\_n[2:] - u\_n[1:-1]) - 0.5\*(q[1:-1] + q[:-2])\*(u\_n[1:-1] - u\_n[:-2])) + \ dt2\*f(x[1:-1], t[n])

#### **2.7.7 A More General PDE Model with Variable Coefficients**

Sometimes a wave PDE has a variable coefficient in front of the time-derivative term:

$$
\varrho(\mathbf{x})\frac{\partial^2 u}{\partial t^2} = \frac{\partial}{\partial \mathbf{x}} \left( q(\mathbf{x})\frac{\partial u}{\partial \mathbf{x}} \right) + f(\mathbf{x}, t) \,. \tag{2.58}
$$

One example appears when modeling elastic waves in a rod with varying density, cf. (2.14.1) with %.x/.

A natural scheme for (2.58) is

$$\left[\varrho D\_l D\_l u = D\_x \overline{q}^\times D\_x u + f\right]\_l^n. \tag{2.59}$$

We realize that the % coefficient poses no particular difficulty, since % enters the formula just as a simple factor in front of a derivative. There is hence no need for any averaging of %. Often, % will be moved to the right-hand side, also without any difficulty:

$$\left[D\_l D\_l u = \varrho^{-1} D\_x \overline{q}^\chi D\_x u + f\right]\_i^n. \tag{2.60}$$

#### **2.7.8 Generalization: Damping**

Waves die out by two mechanisms. In 2D and 3D the energy of the wave spreads out in space, and energy conservation then requires the amplitude to decrease. This effect is not present in 1D. Damping is another cause of amplitude reduction. For example, the vibrations of a string die out because of damping due to air resistance and non-elastic effects in the string.

The simplest way of including damping is to add a first-order derivative to the equation (in the same way as friction forces enter a vibrating mechanical system):

$$\frac{\partial^2 u}{\partial t^2} + b \frac{\partial u}{\partial t} = c^2 \frac{\partial^2 u}{\partial x^2} + f(\mathbf{x}, t), \tag{2.61}$$

where <sup>b</sup> <sup>0</sup> is a prescribed damping coefficient.

A typical discretization of (2.61) in terms of centered differences reads

$$\left[D\_l D\_l u + b D\_{2l} u = c^2 D\_x D\_x u + f\right]\_l^n \,. \tag{2.62}$$

Writing out the equation and solving for the unknown u<sup>n</sup>C<sup>1</sup> <sup>i</sup> gives the scheme

$$u\_{i}^{n+1} = \left(1 + \frac{1}{2}b\Delta t\right)^{-1} \left(\left(\frac{1}{2}b\Delta t - 1\right)u\_{i}^{n-1} + 2u\_{i}^{n}\right.$$

$$+ C^{2}\left(u\_{i+1}^{n} - 2u\_{i}^{n} + u\_{i-1}^{n}\right) + \Delta t^{2}f\_{i}^{n}\right),\tag{2.63}$$

for <sup>i</sup> 2 *<sup>I</sup>*<sup>i</sup> <sup>x</sup> and <sup>n</sup> <sup>1</sup>. New equations must be derived for <sup>u</sup><sup>1</sup> <sup>i</sup> , and for boundary points in case of Neumann conditions.

The damping is very small in many wave phenomena and thus only evident for very long time simulations. This makes the standard wave equation without damping relevant for a lot of applications.

#### **2.8 Building a General 1D Wave Equation Solver**

The program wave1D\_dn\_vc.py is a fairly general code for 1D wave propagation problems that targets the following initial-boundary value problem


The only new feature here is the time-dependent Dirichlet conditions, but they are trivial to implement:

i = Ix[0] # x=0 u[i] = U\_0(t[n+1]) i = Ix[-1] # x=L u[i] = U\_L(t[n+1])

The solver function is a natural extension of the simplest solver function in the initial wave1D\_u0.py program, extended with Neumann boundary conditions (ux <sup>D</sup> <sup>0</sup>), time-varying Dirichlet conditions, as well as a variable wave velocity. The different code segments needed to make these extensions have been shown and commented upon in the preceding text. We refer to the solver function in the wave1D\_dn\_vc.py file for all the details. Note in that solver function, however, that the technique of "hashing" is used to check whether a certain simulation has been run before, or not. This technique is further explained in Sect. C.2.3.

The vectorization is only applied inside the time loop, not for the initial condition or the first time steps, since this initial work is negligible for long time simulations in 1D problems.

The following sections explain various more advanced programming techniques applied in the general 1D wave equation solver.

#### **2.8.1 User Action Function as a Class**

A useful feature in the wave1D\_dn\_vc.py program is the specification of the user\_action function as a class. This part of the program may need some motivation and explanation. Although the plot\_u\_st function (and the PlotMatplotlib class) in the wave1D\_u0.viz function remembers the local variables in the viz function, it is a cleaner solution to store the needed variables together with the function, which is exactly what a class offers.

**The code** A class for flexible plotting, cleaning up files, making movie files, like the function wave1D\_u0.viz did, can be coded as follows:

```
class PlotAndStoreSolution:
    """
    Class for the user_action function in solver.
    Visualizes the solution only.
    """
    def __init__(
        self,
        casename='tmp', # Prefix in filenames
        umin=-1, umax=1, # Fixed range of y axis
        pause_between_frames=None, # Movie speed
        backend='matplotlib', # or 'gnuplot' or None
        screen_movie=True, # Show movie on screen?
        title='', # Extra message in title
        skip_frame=1, # Skip every skip_frame frame
        filename=None): # Name of file with solutions
        self.casename = casename
        self.yaxis = [umin, umax]
        self.pause = pause_between_frames
        self.backend = backend
        if backend is None:
            # Use native matplotlib
            import matplotlib.pyplot as plt
        elif backend in ('matplotlib', 'gnuplot'):
            module = 'scitools.easyviz.' + backend + '_'
            exec('import %s as plt' % module)
        self.plt = plt
        self.screen_movie = screen_movie
        self.title = title
        self.skip_frame = skip_frame
        self.filename = filename
        if filename is not None:
            # Store time points when u is written to file
            self.t = []
            filenames = glob.glob('.' + self.filename + '*.dat.npz')
            for filename in filenames:
                os.remove(filename)
        # Clean up old movie frames
        for filename in glob.glob('frame_*.png'):
            os.remove(filename)
```

```
def __call__(self, u, x, t, n):
    """
    Callback function user_action, call by solver:
    Store solution, plot on screen and save to file.
    """
    # Save solution u to a file using numpy.savez
    if self.filename is not None:
        name = 'u%04d' % n # array name
        kwargs = {name: u}
        fname = '.' + self.filename + '_' + name + '.dat'
        np.savez(fname, **kwargs)
        self.t.append(t[n]) # store corresponding time value
        if n == 0: # save x once
            np.savez('.' + self.filename + '_x.dat', x=x)
    # Animate
    if n % self.skip_frame != 0:
        return
    title = 't=%.3f' % t[n]
    if self.title:
        title = self.title + ' ' + title
    if self.backend is None:
        # native matplotlib animation
        if n == 0:
            self.plt.ion()
            self.lines = self.plt.plot(x, u, 'r-')
            self.plt.axis([x[0], x[-1],
                           self.yaxis[0], self.yaxis[1]])
            self.plt.xlabel('x')
            self.plt.ylabel('u')
            self.plt.title(title)
            self.plt.legend(['t=%.3f' % t[n]])
        else:
            # Update new solution
            self.lines[0].set_ydata(u)
            self.plt.legend(['t=%.3f' % t[n]])
            self.plt.draw()
    else:
        # scitools.easyviz animation
        self.plt.plot(x, u, 'r-',
                      xlabel='x', ylabel='u',
                      axis=[x[0], x[-1],
                            self.yaxis[0], self.yaxis[1]],
                      title=title,
                      show=self.screen_movie)
    # pause
    if t[n] == 0:
        time.sleep(2) # let initial condition stay 2 s
    else:
        if self.pause is None:
            pause = 0.2 if u.size < 100 else 0
        time.sleep(pause)
    self.plt.savefig('frame_%04d.png' % (n))
```
**Dissection** Understanding this class requires quite some familiarity with Python in general and class programming in particular. The class supports plotting with Matplotlib (backend=None) or SciTools (backend=matplotlib or backend= gnuplot) for maximum flexibility.

The constructor shows how we can flexibly import the plotting engine as (typically) scitools.easyviz.gnuplot\_ or scitools.easyviz.matplotlib\_ (note the trailing underscore - it is required). With the screen\_movie parameter we can suppress displaying each movie frame on the screen. Alternatively, for slow movies associated with fine meshes, one can set skip\_frame=10, causing every 10 frames to be shown.

The \_\_call\_\_ method makes PlotAndStoreSolution instances behave like functions, so we can just pass an instance, say p, as the user\_action argument in the solver function, and any call to user\_action will be a call to p.\_\_call\_\_. The \_\_call\_\_ method plots the solution on the screen, saves the plot to file, and stores the solution in a file for later retrieval.

More details on storing the solution in files appear in Sect. C.2.

#### **2.8.2 Pulse Propagation in Two Media**

The function pulse in wave1D\_dn\_vc.py demonstrates wave motion in heterogeneous media where c varies. One can specify an interval where the wave velocity is decreased by a factor slowness\_factor (or increased by making this factor less than one). Figure 2.5 shows a typical simulation scenario.

Four types of initial conditions are available:


These peak-shaped initial conditions can be placed in the middle (loc='center') or at the left end (loc='left') of the domain. With the pulse in the middle, it splits in two parts, each with half the initial amplitude, traveling in opposite directions. With the pulse at the left end, centered at <sup>x</sup> D <sup>0</sup>, and using the symmetry condition @u=@x D <sup>0</sup>, only a right-going pulse is generated. There is also a left-going pulse, but it travels from <sup>x</sup> D <sup>0</sup> in negative <sup>x</sup> direction and is not visible in the domain Œ0; L.

The pulse function is a flexible tool for playing around with various wave shapes and jumps in the wave velocity (i.e., discontinuous media). The code is shown to demonstrate how easy it is to reach this flexibility with the building blocks we have already developed:

```
def pulse(
    C=1, # Maximum Courant number
    Nx=200, # spatial resolution
    animate=True,
    version='vectorized',
    T=2, # end time
    loc='left', # location of initial condition
    pulse_tp='gaussian', # pulse/init.cond. type
    slowness_factor=2, # inverse of wave vel. in right medium
    medium=[0.7, 0.9], # interval for right medium
    skip_frame=1, # skip frames in animations
    sigma=0.05 # width measure of the pulse
    ):
    """
    Various peaked-shaped initial conditions on [0,1].
    Wave velocity is decreased by the slowness_factor inside
    medium. The loc parameter can be 'center' or 'left',
    depending on where the initial pulse is to be located.
    The sigma parameter governs the width of the pulse.
    """
    # Use scaled parameters: L=1 for domain length, c_0=1
    # for wave velocity outside the domain.
    L = 1.0
    c_0 = 1.0
    if loc == 'center':
        xc = L/2
    elif loc == 'left':
        xc = 0
    if pulse_tp in ('gaussian','Gaussian'):
        def I(x):
            return np.exp(-0.5*((x-xc)/sigma)**2)
    elif pulse_tp == 'plug':
        def I(x):
            return 0 if abs(x-xc) > sigma else 1
    elif pulse_tp == 'cosinehat':
        def I(x):
            # One period of a cosine
            w = 2
            a = w*sigma
            return 0.5*(1 + np.cos(np.pi*(x-xc)/a)) \
                   if xc - a <= x <= xc + a else 0
    elif pulse_tp == 'half-cosinehat':
        def I(x):
            # Half a period of a cosine
            w = 4
            a = w*sigma
            return np.cos(np.pi*(x-xc)/a) \
                   if xc - 0.5*a <= x <= xc + 0.5*a else 0
    else:
        raise ValueError('Wrong pulse_tp="%s"' % pulse_tp)
    def c(x):
        return c_0/slowness_factor \
               if medium[0] <= x <= medium[1] else c_0
```

```
umin=-0.5; umax=1.5*I(xc)
    casename = '%s_Nx%s_sf%s' % \
               (pulse_tp, Nx, slowness_factor)
    action = PlotMediumAndSolution(
        medium, casename=casename, umin=umin, umax=umax,
        skip_frame=skip_frame, screen_movie=animate,
        backend=None, filename='tmpdata')
    # Choose the stability limit with given Nx, worst case c
    # (lower C will then use this dt, but smaller Nx)
    dt = (L/Nx)/c_0
    cpu, hashed_input = solver(
        I=I, V=None, f=None, c=c,
        U_0=None, U_L=None,
        L=L, dt=dt, C=C, T=T,
        user_action=action,
        version=version,
        stability_safety_factor=1)
    if cpu > 0: # did we generate new data?
        action.close_file(hashed_input)
        action.make_movie_file()
    print 'cpu (-1 means no new data generated):', cpu
def convergence_rates(
    u_exact,
    I, V, f, c, U_0, U_L, L,
    dt0, num_meshes,
    C, T, version='scalar',
    stability_safety_factor=1.0):
    """
    Half the time step and estimate convergence rates for
    for num_meshes simulations.
    """
    class ComputeError:
        def __init__(self, norm_type):
            self.error = 0
        def __call__(self, u, x, t, n):
            """Store norm of the error in self.E."""
            error = np.abs(u - u_exact(x, t[n])).max()
            self.error = max(self.error, error)
    E = []
    h = [] # dt, solver adjusts dx such that C=dt*c/dx
    dt = dt0
    for i in range(num_meshes):
        error_calculator = ComputeError('Linf')
        solver(I, V, f, c, U_0, U_L, L, dt, C, T,
               user_action=error_calculator,
               version='scalar',
               stability_safety_factor=1.0)
        E.append(error_calculator.error)
        h.append(dt)
        dt /= 2 # halve the time step for next simulation
    print 'E:', E
    print 'h:', h
    r = [np.log(E[i]/E[i-1])/np.log(h[i]/h[i-1])
         for i in range(1,num_meshes)]
    return r
```

```
def test_convrate_sincos():
    n = m = 2
    L = 1.0
    u_exact = lambda x, t: np.cos(m*np.pi/L*t)*np.sin(m*np.pi/L*x)
    r = convergence_rates(
        u_exact=u_exact,
        I=lambda x: u_exact(x, 0),
        V=lambda x: 0,
        f=0,
        c=1,
        U_0=0,
        U_L=0,
        L=L,
        dt0=0.1,
        num_meshes=6,
        C=0.9,
        T=1,
        version='scalar',
        stability_safety_factor=1.0)
    print 'rates sin(x)*cos(t) solution:', \
          [round(r_,2) for r_ in r]
    assert abs(r[-1] - 2) < 0.002
```
The PlotMediumAndSolution class used here is a subclass of PlotAndStore Solution where the medium with reduced c value, as specified by the medium interval, is visualized in the plots.

#### **Comment on the choices of discretization parameters**

The argument Nx in the pulse function does not correspond to the actual spatial resolution of C <1, since the solver function takes a fixed t and C, and adjusts x accordingly. As seen in the pulse function, the specified t is chosen according to the limit <sup>C</sup> D <sup>1</sup>, so if C <1, t remains the same, but the solver function operates with a larger x and smaller Nx than was specified in the call to pulse. The practical reason is that we always want to keep t fixed such that plot frames and movies are synchronized in time regardless of the value of C (i.e., x is varied when the Courant number varies).

The reader is encouraged to play around with the pulse function:

```
>>> import wave1D_dn_vc as w
>>> w.pulse(Nx=50, loc='left', pulse_tp='cosinehat', slowness_factor=2)
```
To easily kill the graphics by Ctrl-C and restart a new simulation it might be easier to run the above two statements from the command line with

```
Terminal
Terminal> python -c 'import wave1D_dn_vc as w; w.pulse(...)'
```
#### **2.9 Exercises**

#### **Exercise 2.7: Find the analytical solution to a damped wave equation**

Consider the wave equation with damping (2.61). The goal is to find an exact solution to a wave problem with damping and zero source term. A starting point is the standing wave solution from Exercise 2.1. It becomes necessary to include a damping term eˇt and also have both a sine and cosine component in time:

$$u\_{\mathfrak{C}}(\mathfrak{x},t) = e^{-\beta t} \sin k \,\mathfrak{x} \left( A \cos \alpha t + B \sin \alpha t \right) \,\, .$$

Find <sup>k</sup> from the boundary conditions u.0; t/ D u.L; t/ D <sup>0</sup>. Then use the PDE to find constraints on ˇ, !, A, and B. Set up a complete initial-boundary value problem and its solution.

Filename: damped\_waves.

#### **Problem 2.8: Explore symmetry boundary conditions**

Consider the simple "plug" wave where ˝ D <sup>Œ</sup>L; L and

$$I(\mathbf{x}) = \begin{cases} \text{l}, & \mathbf{x} \in [-\delta, \delta], \\ 0, & \text{otherwise} \end{cases}$$

for some number 0<ı<L. The other initial condition is ut.x; 0/ D <sup>0</sup> and there is no source term <sup>f</sup> . The boundary conditions can be set to <sup>u</sup> D <sup>0</sup>. The solution to this problem is symmetric around <sup>x</sup> D <sup>0</sup>. This means that we can simulate the wave process in only half of the domain Œ0; L.

a) Argue why the symmetry boundary condition is ux <sup>D</sup> <sup>0</sup> at <sup>x</sup> <sup>D</sup> <sup>0</sup>.

*Hint* Symmetry of a function about <sup>x</sup> <sup>D</sup> x0 means that f .x0 <sup>C</sup> h/ <sup>D</sup> f .x0 h/.


Filename: wave1D\_symmetric.

#### **Exercise 2.9: Send pulse waves through a layered medium**

Use the pulse function in wave1D\_dn\_vc.py to investigate sending a pulse, located with its peak at <sup>x</sup> D <sup>0</sup>, through two media with different wave velocities. The (scaled) velocity in the left medium is 1 while it is <sup>1</sup> sf in the right medium. Report what happens with a Gaussian pulse, a "cosine hat" pulse, half a "cosine hat" pulse, and a plug pulse for resolutions Nx <sup>D</sup> 40; 80; 160, and sf <sup>D</sup> 2; 4. Simulate until <sup>T</sup> D <sup>2</sup>.

Filename: pulse1D.

#### **Exercise 2.10: Explain why numerical noise occurs**

The experiments performed in Exercise 2.9 shows considerable numerical noise in the form of non-physical waves, especially for sf <sup>D</sup> <sup>4</sup> and the plug pulse or the half a "cosinehat" pulse. The noise is much less visible for a Gaussian pulse. Run the case with the plug and half a "cosinehat" pulse for sf <sup>D</sup> <sup>1</sup>, <sup>C</sup> <sup>D</sup> 0:9; 0:25, and Nx <sup>D</sup> 40; 80; 160. Use the numerical dispersion relation to explain the observations.

Filename: pulse1D\_analysis.

#### **Exercise 2.11: Investigate harmonic averaging in a 1D model**

Harmonic means are often used if the wave velocity is non-smooth or discontinuous. Will harmonic averaging of the wave velocity give less numerical noise for the case sf <sup>D</sup> <sup>4</sup> in Exercise 2.9?

Filename: pulse1D\_harmonic.

#### **Problem 2.12: Implement open boundary conditions**

To enable a wave to leave the computational domain and travel undisturbed through the boundary <sup>x</sup> D <sup>L</sup>, one can in a one-dimensional problem impose the following condition, called a *radiation condition* or *open boundary condition*:

$$c\frac{\partial u}{\partial t} + c\frac{\partial u}{\partial x} = 0 \,. \tag{2.69}$$

The parameter c is the wave velocity.

Show that (2.69) accepts a solution <sup>u</sup> <sup>D</sup> gR.x ct/ (right-going wave), but not <sup>u</sup> D gL.xCct/ (left-going wave). This means that (2.69) will allow any right-going wave gR.x ct/ to pass through the boundary undisturbed.

A corresponding open boundary condition for a left-going wave through <sup>x</sup> D <sup>0</sup> is

$$\frac{\partial u}{\partial t} - c \frac{\partial u}{\partial x} = 0 \,. \tag{2.70}$$

a) A natural idea for discretizing the condition (2.69) at the spatial end point <sup>i</sup> D Nx is to apply centered differences in time and space:

$$[D\_{2l}u + cD\_{2\chi}u = 0]\_l^n, \quad i = N\_\chi \,. \tag{2.71}$$

Eliminate the fictitious value u<sup>n</sup> NxC<sup>1</sup> by using the discrete equation at the same point.

The equation for the first step, u<sup>1</sup> <sup>i</sup> , is in principle also affected, but we can then use the condition uNx <sup>D</sup> <sup>0</sup> since the wave has not yet reached the right boundary. b) A much more convenient implementation of the open boundary condition at <sup>x</sup> D <sup>L</sup> can be based on an explicit discretization

$$[D\_l^+u + cD\_x^-u = 0]\_l^n, \quad i = N\_x \,. \tag{2.72}$$

From this equation, one can solve for u<sup>n</sup>C<sup>1</sup> Nx and apply the formula as a Dirichlet condition at the boundary point. However, the finite difference approximations involved are of first order.

Implement this scheme for a wave equation utt <sup>D</sup> <sup>c</sup>2uxx in a domain Œ0; L, where you have ux <sup>D</sup> <sup>0</sup> at <sup>x</sup> <sup>D</sup> <sup>0</sup>, the condition (2.69) at <sup>x</sup> <sup>D</sup> <sup>L</sup>, and an initial disturbance in the middle of the domain, e.g., a plug profile like

$$u(\mathbf{x},0) = \begin{cases} 1, & L/2 - \ell \le \mathbf{x} \le L/2 + \ell, \\ 0, & \text{otherwise} \text{ .} \end{cases}$$

Observe that the initial wave is split in two, the left-going wave is reflected at <sup>x</sup> D <sup>0</sup>, and both waves travel out of <sup>x</sup> D <sup>L</sup>, leaving the solution as <sup>u</sup> D <sup>0</sup> in Œ0; L. Use a unit Courant number such that the numerical solution is exact. Make a movie to illustrate what happens.

Because this simplified implementation of the open boundary condition works, there is no need to pursue the more complicated discretization in a).

*Hint* Modify the solver function in wave1D\_dn.py.

c) Add the possibility to have either ux <sup>D</sup> <sup>0</sup> or an open boundary condition at the left boundary. The latter condition is discretized as

$$[D\_l^+u - cD\_x^+u = 0]\_i^n, \quad i = 0,\tag{2.73}$$

leading to an explicit update of the boundary value u<sup>n</sup>C<sup>1</sup> <sup>0</sup> . The implementation can be tested with a Gaussian function as initial condition:

$$\lg(\mathbf{x}; m, s) = \frac{1}{\sqrt{2\pi s}} e^{-\frac{(\mathbf{x} - \mathbf{m})^2}{2s^2}}.$$

Run two tests:


Make test functions for both cases, testing that the solution is zero after the waves have left the domain.

d) In 2D and 3D it is difficult to compute the correct wave velocity normal to the boundary, which is needed in generalizations of the open boundary conditions in higher dimensions. Test the effect of having a slightly wrong wave velocity in (2.72). Make movies to illustrate what happens.

Filename: wave1D\_open\_BC.

*Remarks* The condition (2.69) works perfectly in 1D when c is known. In 2D and 3D, however, the condition reads ut <sup>C</sup> cxux <sup>C</sup> cyuy <sup>D</sup> <sup>0</sup>, where cx and cy are the wave speeds in the x and y directions. Estimating these components (i.e., the direction of the wave) is often challenging. Other methods are normally used in 2D and 3D to let waves move out of a computational domain.

#### **Exercise 2.13: Implement periodic boundary conditions**

It is frequently of interest to follow wave motion over large distances and long times. A straightforward approach is to work with a very large domain, but that might lead to a lot of computations in areas of the domain where the waves cannot be noticed. A more efficient approach is to let a right-going wave out of the domain and at the same time let it enter the domain on the left. This is called a *periodic boundary condition*.

The boundary condition at the right end <sup>x</sup> D <sup>L</sup> is an open boundary condition (see Exercise 2.12) to let a right-going wave out of the domain. At the left end, <sup>x</sup> D <sup>0</sup>, we apply, in the beginning of the simulation, either a symmetry boundary condition (see Exercise 2.8) ux <sup>D</sup> <sup>0</sup>, or an open boundary condition.

This initial wave will split in two and either be reflected or transported out of the domain at <sup>x</sup> D <sup>0</sup>. The purpose of the exercise is to follow the right-going wave. We can do that with a *periodic boundary condition*. This means that when the rightgoing wave hits the boundary <sup>x</sup> D <sup>L</sup>, the open boundary condition lets the wave out of the domain, but at the same time we use a boundary condition on the left end <sup>x</sup> D <sup>0</sup> that feeds the outgoing wave into the domain again. This periodic condition is simply u.0/ <sup>D</sup> u.L/. The switch from ux <sup>D</sup> <sup>0</sup> or an open boundary condition at the left end to a periodic condition can happen when u.L; t/ > , where D <sup>10</sup><sup>4</sup> might be an appropriate value for determining when the right-going wave hits the boundary <sup>x</sup> D <sup>L</sup>.

The open boundary conditions can conveniently be discretized as explained in Exercise 2.12. Implement the described type of boundary conditions and test them on two different initial shapes: a plug u.x; 0/ D <sup>1</sup> for <sup>x</sup> 0:1, u.x; 0/ D <sup>0</sup> for x > 0:1, and a Gaussian function in the middle of the domain: u.x; 0/ D exp .<sup>1</sup> <sup>2</sup> .x 0:5/2=0:05/. The domain is the unit interval Œ0; 1. Run these two shapes for Courant numbers 1 and 0.5. Assume constant wave velocity. Make movies of the four cases. Reason why the solutions are correct. Filename: periodic.

#### **Exercise 2.14: Compare discretizations of a Neumann condition**

We have a 1D wave equation with variable wave velocity: utt <sup>D</sup> .qux/x. A Neumann condition ux at <sup>x</sup> <sup>D</sup> 0; L can be discretized as shown in (2.54) and (2.57).

The aim of this exercise is to examine the rate of the numerical error when using different ways of discretizing the Neumann condition.


mation to qi , qi1=2 <sup>D</sup> qi <sup>C</sup> 0:25q<sup>00</sup> <sup>i</sup> x<sup>2</sup> C , because <sup>q</sup><sup>0</sup> <sup>i</sup> <sup>D</sup> <sup>0</sup> for <sup>i</sup> <sup>D</sup> Nx (a similar argument can be applied to the case <sup>i</sup> D <sup>0</sup>).


$$\left[\left[D\_{l}D\_{l}u\right]\_{i}^{n}\right] = \frac{1}{\Delta x}\left(\left[qD\_{x}u\right]\_{i+\frac{1}{2}}^{n} - \left[qD\_{x}u\right]\_{i-\frac{1}{2}}^{n}\right) + \left[f\right]\_{i}^{n},$$

and place the boundary at xiC <sup>1</sup> 2 , <sup>i</sup> D Nx, instead of exactly at the physical boundary. With this idea of approximating (moving) the boundary, we can just set ŒqDxu<sup>n</sup> iC 1 2 D <sup>0</sup>. Derive the complete scheme using this technique. The implementation of the boundary condition at <sup>L</sup> x=2 is *<sup>O</sup>*.x2/ accurate, but the interesting question is what impact the movement of the boundary has on the convergence rate. Compute the errors as usual over the entire mesh and use q from a) or b).

Filename: Neumann\_discr.

#### **Exercise 2.15: Verification by a cubic polynomial in space**

The purpose of this exercise is to verify the implementation of the solver function in the program wave1D\_n0.py by using an exact numerical solution for the wave equation utt <sup>D</sup> <sup>c</sup>2uxx <sup>C</sup> <sup>f</sup> with Neumann boundary conditions ux.0; t/ <sup>D</sup> ux.L; t/ D <sup>0</sup>.

A similar verification is used in the file wave1D\_u0.py, which solves the same PDE, but with Dirichlet boundary conditions u.0; t/ D u.L; t/ D <sup>0</sup>. The idea of the verification test in function test\_quadratic in wave1D\_u0.py is to produce a solution that is a lower-order polynomial such that both the PDE problem, the boundary conditions, and all the discrete equations are exactly fulfilled. Then the solver function should reproduce this exact solution to machine precision. More precisely, we seek <sup>u</sup> D X.x/T .t/, with T .t/ as a linear function and X.x/ as a parabola that fulfills the boundary conditions. Inserting this u in the PDE determines f . It turns out that u also fulfills the discrete equations, because the truncation error of the discretized PDE has derivatives in x and t of order four and higher. These derivatives all vanish for a quadratic X.x/ and linear T .t/.

It would be attractive to use a similar approach in the case of Neumann conditions. We set <sup>u</sup> D X.x/T .t/ and seek lower-order polynomials <sup>X</sup> and <sup>T</sup> . To force ux to vanish at the boundary, we let Xx be a parabola. Then X is a cubic polynomial. The fourth-order derivative of a cubic polynomial vanishes, so <sup>u</sup> D X.x/T .t/ will fulfill the discretized PDE also in this case, if f is adjusted such that u fulfills the PDE.

However, the discrete boundary condition is not exactly fulfilled by this choice of u. The reason is that

$$[D\_{2\times}u]\_i^n = u\_\times(\mathbf{x}\_i, t\_n) + \frac{1}{6}u\_{\times \times \times}(\mathbf{x}\_i, t\_n)\Delta x^2 + \mathcal{O}(\Delta x^4) \,. \tag{2.74}$$

At the two boundary points, we must demand that the derivative Xx.x/ D <sup>0</sup> such that ux <sup>D</sup> <sup>0</sup>. However, uxxx is a constant and not zero when X.x/ is a cubic polynomial. Therefore, our <sup>u</sup> D X.x/T .t/ fulfills

$$[D\_{2\times}u]\_i^n = \frac{1}{6}u\_{\times \times \times}(\chi\_i, t\_n)\Delta x^2,$$

and not

$$[D\_{2\times}u]\_i^n = 0, \quad i = 0, N\_{\times},$$

as it should. (Note that all the higher-order terms *<sup>O</sup>*.x4/ also have higher-order derivatives that vanish for a cubic polynomial.) So to summarize, the fundamental problem is that u as a product of a cubic polynomial and a linear or quadratic polynomial in time is not an exact solution of the discrete boundary conditions.

To make progress, we assume that <sup>u</sup> D X.x/T .t/, where <sup>T</sup> for simplicity is taken as a prescribed linear function <sup>1</sup>C <sup>1</sup> <sup>2</sup> t, and X.x/ is taken as an *unknown* cubic polynomial P<sup>3</sup> <sup>j</sup> D<sup>0</sup> aj <sup>x</sup><sup>j</sup> . There are two different ways of determining the coefficients a0;:::;a3 such that both the discretized PDE and the discretized boundary conditions are fulfilled, under the constraint that we can specify a function f .x; t/ for the PDE to feed to the solver function in wave1D\_n0.py. Both approaches are explained in the subexercises.

a) One can insert u in the discretized PDE and find the corresponding f . Then one can insert u in the discretized boundary conditions. This yields two equations for the four coefficients a0;:::;a3. To find the coefficients, one can set a0 <sup>D</sup> <sup>0</sup> and a1 <sup>D</sup> <sup>1</sup> for simplicity and then determine a2 and a3. This approach will make a2 and a3 depend on x and f will depend on both x and t.

Use sympy to perform analytical computations. A starting point is to define u as follows:

```
def test_cubic1():
    import sympy as sm
    x, t, c, L, dx, dt = sm.symbols('x t c L dx dt')
    i, n = sm.symbols('i n', integer=True)
    # Assume discrete solution is a polynomial of degree 3 in x
    T = lambda t:1+ sm.Rational(1,2)*t # Temporal term
    a = sm.symbols('a_0 a_1 a_2 a_3')
    X = lambda x: sum(a[q]*x**q for q in range(4)) # Spatial term
    u = lambda x, t: X(x)*T(t)
```
The symbolic expression for u is reached by calling u(x,t) with x and t as sympy symbols.

Define DxDx(u, i, n), DtDt(u, i, n), and D2x(u, i, n) as Python functions for returning the difference approximations ŒDxDxu<sup>n</sup> <sup>i</sup> , ŒDtDtu<sup>n</sup> <sup>i</sup> , and ŒD2xu<sup>n</sup> <sup>i</sup> . The next step is to set up the residuals for the equations ŒD2xu<sup>n</sup> <sup>0</sup> <sup>D</sup> <sup>0</sup> and ŒD2xu<sup>n</sup> Nx <sup>D</sup> <sup>0</sup>, where Nx <sup>D</sup> L=x. Call the residuals R\_0 and R\_L. Substitute a0 and a1 by 0 and 1, respectively, in R\_0, R\_L, and a:

```
R_0 = R_0.subs(a[0], 0).subs(a[1], 1)
R_L = R_L.subs(a[0], 0).subs(a[1], 1)
a = list(a) # enable in-place assignment
a[0:2] = 0, 1
```
Determining a2 and a3 from the discretized boundary conditions is then about solving two equations with respect to a2 and a3, i.e., a[2:]:

```
s = sm.solve([R_0, R_L], a[2:])
# s is dictionary with the unknowns a[2] and a[3] as keys
a[2:] = s[a[2]], s[a[3]]
```
Now, a contains computed values and u will automatically use these new values since X accesses a.

Compute the source term f from the discretized PDE: f <sup>n</sup> <sup>i</sup> <sup>D</sup> ŒDtDtu c2DxDxun <sup>i</sup> . Turn u, the time derivative ut (needed for the initial condition V .x/), and f into Python functions. Set numerical values for L, Nx, <sup>C</sup>, and <sup>c</sup>. Prescribe the time interval as t D CL=.Nxc/, which imply x D ct=C D L=Nx. Define new functions I(x), V(x), and f(x,t) as wrappers of the ones made above, where fixed values of L, c, x, and t are inserted, such that I, V, and f can be passed on to the solver function. Finally, call solver with a user\_action function that compares the numerical solution to this exact solution u of the discrete PDE problem.

*Hint* To turn a sympy expression e, depending on a series of symbols, say x, t, dx, dt, L, and c, into a plain Python function e\_exact(x,t,L,dx,dt,c), one can write

e\_exact = sm.lambdify([x,t,L,dx,dt,c], e, 'numpy')

The 'numpy' argument is a good habit as the e\_exact function will then work with array arguments if it contains mathematical functions (but here we only do plain arithmetics, which automatically work with arrays).

b) An alternative way of determining a0;:::;a3 is to reason as follows. We first construct X.x/ such that the boundary conditions are fulfilled: <sup>X</sup> D x.L x/. However, to compensate for the fact that this choice of X does not fulfill the discrete boundary condition, we seek u such that

$$u\_{\times} = \frac{\partial}{\partial \boldsymbol{x}} \boldsymbol{x}(\boldsymbol{L} - \boldsymbol{x})T(t) - \frac{1}{6}u\_{\times \times \times} \Delta \boldsymbol{x}^2,$$

since this <sup>u</sup> will fit the discrete boundary condition. Assuming <sup>u</sup> D T .t/P<sup>3</sup> <sup>j</sup> D<sup>0</sup> aj <sup>x</sup><sup>j</sup> , we can use the above equation to determine the coefficients a1; a2; a3. A value, e.g., 1 can be used for a0. The following sympy code computes this u:

```
def test_cubic2():
    import sympy as sm
    x, t, c, L, dx = sm.symbols('x t c L dx')
    T = lambda t:1+ sm.Rational(1,2)*t # Temporal term
    # Set u as a 3rd-degree polynomial in space
    X = lambda x: sum(a[i]*x**i for i in range(4))
    a = sm.symbols('a_0 a_1 a_2 a_3')
    u = lambda x, t: X(x)*T(t)
    # Force discrete boundary condition to be zero by adding
    # a correction term the analytical suggestion x*(L-x)*T
    # u_x = x*(L-x)*T(t) - 1/6*u_xxx*dx**2
    R = sm.diff(u(x,t), x)-(
        x*(L-x) - sm.Rational(1,6)*sm.diff(u(x,t), x, x, x)*dx**2)
    # R is a polynomial: force all coefficients to vanish.
    # Turn R to Poly to extract coefficients:
    R = sm.poly(R, x)
    coeff = R.all_coeffs()
    s = sm.solve(coeff, a[1:]) # a[0] is not present in R
    # s is dictionary with a[i] as keys
    # Fix a[0] as 1
    s[a[0]] = 1
    X = lambda x: sm.simplify(sum(s[a[i]]*x**i for i in range(4)))
    u = lambda x, t: X(x)*T(t)
    print 'u:', u(x,t)
```
The next step is to find the source term f\_e by inserting u\_e in the PDE. Thereafter, turn u, f, and the time derivative of u into plain Python functions as in a), and then wrap these functions in new functions I, V, and f, with the right signature as required by the solver function. Set parameters as in a) and check that the solution is exact to machine precision at each time level using an appropriate user\_action function.

Filename: wave1D\_n0\_test\_cubic.

#### **2.10 Analysis of the Difference Equations**

#### **2.10.1 Properties of the Solution of the Wave Equation**

The wave equation

$$\frac{\partial^2 u}{\partial t^2} = c^2 \frac{\partial^2 u}{\partial x^2}$$

has solutions of the form

$$\mathbf{u}(\mathbf{x},t) = \mathbf{g}\_{\mathcal{R}}(\mathbf{x} - ct) + \mathbf{g}\_{L}(\mathbf{x} + ct),\tag{2.75}$$

for any functions gR and gL sufficiently smooth to be differentiated twice. The result follows from inserting (2.75) in the wave equation. A function of the form gR.x ct/ represents a signal moving to the right in time with constant velocity <sup>c</sup>. This feature can be explained as follows. At time <sup>t</sup> <sup>D</sup> <sup>0</sup> the signal looks like gR.x/. Introducing a moving horizontal coordinate D <sup>x</sup> ct, we see the function gR./ is "at rest" in the coordinate system, and the shape is always the same. Say the gR./ function has a peak at D <sup>0</sup>. This peak is located at <sup>x</sup> D ct, which means that it moves with the velocity dx=dt D <sup>c</sup> in the <sup>x</sup> coordinate system. Similarly, gL.x C ct/ is a function, initially with shape gL.x/, that moves in the negative <sup>x</sup> direction with constant velocity <sup>c</sup> (introduce D <sup>x</sup> C ct, look at the point D <sup>0</sup>, <sup>x</sup> D ct, which has velocity dx=dt D c).

With the particular initial conditions

$$
\mu(\mathbf{x},0) = I(\mathbf{x}), \quad \frac{\partial}{\partial t} \mu(\mathbf{x},0) = 0,
$$

we get, with u as in (2.75),

$$\mathbf{g}\_R(\mathbf{x}) + \mathbf{g}\_L(\mathbf{x}) = I(\mathbf{x}), \quad -c\mathbf{g}\_R'(\mathbf{x}) + c\mathbf{g}\_L'(\mathbf{x}) = 0 \,.$$

The former suggests gR <sup>D</sup> gL, and the former then leads to gR <sup>D</sup> gL <sup>D</sup> I=2. Consequently,

$$u(\mathbf{x},t) = \frac{1}{2}I(\mathbf{x} - ct) + \frac{1}{2}I(\mathbf{x} + ct) \,. \tag{2.76}$$

The interpretation of (2.76) is that the initial shape of u is split into two parts, each with the same shape as I but half of the initial amplitude. One part is traveling to the left and the other one to the right.

The solution has two important physical features: constant amplitude of the left and right wave, and constant velocity of these two waves. It turns out that the numerical solution will also preserve the constant amplitude, but the velocity depends on the mesh parameters t and x.

The solution (2.76) will be influenced by boundary conditions when the parts 1 <sup>2</sup> I.x ct/ and <sup>1</sup> <sup>2</sup> I.x <sup>C</sup> ct/ hit the boundaries and get, e.g., reflected back into the domain. However, when I.x/ is nonzero only in a small part in the middle of the spatial domain Œ0; L, which means that the boundaries are placed far away from the initial disturbance of u, the solution (2.76) is very clearly observed in a simulation.

A useful representation of solutions of wave equations is a linear combination of sine and/or cosine waves. Such a sum of waves is a solution if the governing PDE is linear and each sine or cosine wave fulfills the equation. To ease analytical calculations by hand we shall work with complex exponential functions instead of real-valued sine or cosine functions. The real part of complex expressions will typically be taken as the physical relevant quantity (whenever a physical relevant quantity is strictly needed). The idea now is to build I.x/ of complex wave components eikx:

$$I(\mathbf{x}) \approx \sum\_{k \in K} b\_k e^{ik\mathbf{x}} \,. \tag{2.77}$$

Here, k is the frequency of a component, K is some set of all the discrete k values needed to approximate I.x/ well, and bk are constants that must be determined. We will very seldom need to compute the bk coefficients: most of the insight we look for, and the understanding of the numerical methods we want to establish, come from investigating how the PDE and the scheme treat a single component eikx wave.

Letting the number of k values in K tend to infinity, makes the sum (2.77) converge to I.x/. This sum is known as a *Fourier series* representation of I.x/. Looking at (2.76), we see that the solution u.x; t/, when I.x/ is represented as in (2.77), is also built of basic complex exponential wave components of the form eik.x˙ct/ according to

$$u(\mathbf{x},t) = \frac{1}{2} \sum\_{k \in K} b\_k e^{ik(\mathbf{x} - \mathbf{c}t)} + \frac{1}{2} \sum\_{k \in K} b\_k e^{ik(\mathbf{x} + \mathbf{c}t)} \,. \tag{2.78}$$

It is common to introduce the frequency in time ! D kc and assume that u.x; t/ is a sum of basic wave components written as eikx!t . (Observe that inserting such a wave component in the governing PDE reveals that !<sup>2</sup> D <sup>k</sup>2c2, or ! D ˙kc, reflecting the two solutions: one (Ckc) traveling to the right and the other (kc) traveling to the left.)

#### **2.10.2 More Precise Definition of Fourier Representations**

The above introduction to function representation by sine and cosine waves was quick and intuitive, but will suffice as background knowledge for the following material of single wave component analysis. However, to understand all details of how different wave components sum up to the analytical and numerical solutions, a more precise mathematical treatment is helpful and therefore summarized below.

It is well known that periodic functions can be represented by Fourier series. A generalization of the Fourier series idea to non-periodic functions defined on the real line is the *Fourier transform*:

$$I(\mathbf{x}) = \int\_{-\infty}^{\infty} A(k)e^{ik\mathbf{x}} \, dk,\tag{2.79}$$

$$A(k) = \int\_{-\infty}^{\infty} I(\mathbf{x}) e^{-ik\mathbf{x}} d\mathbf{x} \,. \tag{2.80}$$

The function A.k/ reflects the weight of each wave component eikx in an infinite sum of such wave components. That is, A.k/ reflects the frequency content in the function I.x/. Fourier transforms are particularly fundamental for analyzing and understanding time-varying signals.

The solution of the linear 1D wave PDE can be expressed as

$$u(\mathbf{x},t) = \int\_{-\infty}^{\infty} A(k)e^{i(k\cdot x - \omega(k)t)}d\mathbf{x}\,\,.$$

In a finite difference method, we represent u by a mesh function u<sup>n</sup> <sup>q</sup> , where n counts temporal mesh points and q counts the spatial ones (the usual counter for spatial points, i, is here already used as imaginary unit). Similarly, I.x/ is approximated by the mesh function Iq , <sup>q</sup> <sup>D</sup> 0; : : : ; Nx. On a mesh, it does not make sense to work with wave components eikx for very large k, because the shortest possible sine or cosine wave that can be represented uniquely on a mesh with spacing x is the wave with wavelength 2x. This wave has its peaks and throughs at every two mesh points. That is, the wave "jumps up and down" between the mesh points.

The corresponding <sup>k</sup> value for the shortest possible wave in the mesh is <sup>k</sup> D 2=.2x/ D =x. This maximum frequency is known as the *Nyquist frequency*. Within the range of relevant frequencies .0; =x one defines the discrete Fourier transform11, using Nx <sup>C</sup> <sup>1</sup> discrete frequencies:

$$I\_q = \frac{1}{N\_\chi + 1} \sum\_{k=0}^{N\_\chi} A\_k e^{i2\pi kq/(N\_\chi + 1)}, \quad q = 0, \dots, N\_\chi,\tag{2.81}$$

$$A\_k = \sum\_{q=0}^{N\_x} I\_q e^{-i2\pi kq/(N\_x+1)}, \quad k = 0, \ldots, N\_x \,. \tag{2.82}$$

The Ak values represent the discrete Fourier transform of the Iq values, which themselves are the inverse discrete Fourier transform of the Ak values.

The discrete Fourier transform is efficiently computed by the *Fast Fourier transform* algorithm. For a real function I.x/, the relevant Python code for computing and plotting the discrete Fourier transform appears in the example below.

```
import numpy as np
from numpy import sin, pi
def I(x):
    return sin(2*pi*x) + 0.5*sin(4*pi*x) + 0.1*sin(6*pi*x)
# Mesh
L = 10; Nx = 100
x = np.linspace(0, L, Nx+1)
dx = L/float(Nx)
# Discrete Fourier transform
A = np.fft.rfft(I(x))
A_amplitude = np.abs(A)
# Compute the corresponding frequencies
freqs = np.linspace(0, pi/dx, A_amplitude.size)
import matplotlib.pyplot as plt
plt.plot(freqs, A_amplitude)
plt.show()
```
#### **2.10.3 Stability**

The scheme

$$\left[D\_l D\_l u = c^2 D\_x D\_x u\right]\_q^n \tag{2.83}$$

<sup>11</sup> http://en.wikipedia.org/wiki/Discrete\_Fourier\_transform

for the wave equation utt <sup>D</sup> <sup>c</sup>2uxx allows basic wave components

$$u\_q'' = e^{i(k\chi\_q - \bar{\alpha}t\_0)}$$

as solution, but it turns out that the frequency in time, !Q, is not equal to the exact frequency ! D kc. The goal now is to find exactly what !Q is. We ask two key questions:


The following analysis will answer these questions. We shall continue using q as an identifier for a certain mesh point in the x direction.

**Preliminary results** A key result needed in the investigations is the finite difference approximation of a second-order derivative acting on a complex wave component:

$$[D\_t D\_t e^{i\alpha t}]^n = -\frac{4}{\Delta t^2} \sin^2 \left(\frac{a\Delta t}{2}\right) e^{i\alpha n\Delta t} \dots$$

By just changing symbols (! ! <sup>k</sup>, <sup>t</sup> ! <sup>x</sup>, <sup>n</sup> ! <sup>q</sup>) it follows that

$$[D\_{\underline{\times}}D\_{\underline{\times}}e^{ik\underline{\times}}]\_q = -\frac{4}{\Delta x^2} \sin^2\left(\frac{k\Delta x}{2}\right) e^{ikq\Delta x} \dots$$

**Numerical wave propagation** Inserting a basic wave component u<sup>n</sup> <sup>q</sup> <sup>D</sup> <sup>e</sup>i.kxq Q!tn/ in (2.83) results in the need to evaluate two expressions:

$$\begin{aligned} [D\_l D\_l e^{ik\chi} e^{-i\tilde{\omega}l}]^n\_q &= [D\_l D\_l e^{-i\tilde{\omega}l}]^n\_q e^{ikq\Delta x} \\ &= -\frac{4}{\Delta t^2} \sin^2\left(\frac{\tilde{\omega}\Delta t}{2}\right) e^{-i\tilde{\omega}n\Delta t} e^{ikq\Delta x} \\ \text{(D. D. } a^{ik\chi} e^{-i\tilde{\omega}l} l^n &= [D\_l D\_l e^{ik\chi}]^n\_q e^{-i\tilde{\omega}n\Delta t} \end{aligned} \tag{2.84}$$

$$\begin{split} [D\_{\times}D\_{\times}e^{i\mathbf{k}\cdot\mathbf{x}}e^{-i\tilde{\alpha}l}]\_{q}^{n} &= [D\_{\times}D\_{\times}e^{ik\cdot\mathbf{x}}]\_{q}e^{-i\tilde{\alpha}n\Delta l} \\ &= -\frac{4}{\Delta x^{2}}\sin^{2}\left(\frac{k\Delta\chi}{2}\right)e^{ikq\Delta x}e^{-i\tilde{\alpha}n\Delta l} .\end{split} \tag{2.85}$$

Then the complete scheme,

$$\left[D\_{l}D\_{l}e^{ik\chi}e^{-i\tilde{\alpha}t} = c^{2}D\_{\chi}D\_{\chi}e^{ik\chi}e^{-i\tilde{\alpha}t}\right]\_{q}^{n}$$

leads to the following equation for the unknown numerical frequency !Q (after dividing by eikxei!t <sup>Q</sup> ):

$$
\frac{4}{\Delta t^2} \sin^2 \left(\frac{\tilde{\nu}\Delta t}{2}\right) = c^2 \frac{4}{\Delta x^2} \sin^2 \left(\frac{k\Delta x}{2}\right),
$$

or

$$
\sin^2\left(\frac{\tilde{\omega}\,\Delta t}{2}\right) = C^2 \sin^2\left(\frac{k\Delta x}{2}\right),
\tag{2.86}
$$

where

$$C = \frac{c\Delta t}{\Delta x} \tag{2.87}$$

is the Courant number. Taking the square root of (2.86) yields

$$\sin\left(\frac{\tilde{\alpha}\Delta t}{2}\right) = C\sin\left(\frac{k\Delta x}{2}\right). \tag{2.88}$$

Since the exact ! is real it is reasonable to look for a real solution !Q of (2.88). The right-hand side of (2.88) must then be in <sup>Œ</sup>1; 1 because the sine function on the left-hand side has values in <sup>Œ</sup>1; 1 for real !Q. The magnitude of the sine function on the right-hand side attains the value 1 when

$$\frac{k\Delta x}{2} = \frac{\pi}{2} + m\pi, \quad m \in \mathbb{Z}\_+$$

With <sup>m</sup> D <sup>0</sup> we have kx D , which means that the wavelength D 2=k becomes 2x. This is the absolutely shortest wavelength that can be represented on the mesh: the wave jumps up and down between each mesh point. Larger values of jmj are irrelevant since these correspond to <sup>k</sup> values whose waves are too short to be represented on a mesh with spacing x. For the shortest possible wave in the mesh, sin .kx=2/ D <sup>1</sup>, and we must require

$$\mathcal{C} \le 1. \tag{2.89}$$

Consider a right-hand side in (2.88) of magnitude larger than unity. The solution !<sup>Q</sup> of (2.88) must then be a complex number !<sup>Q</sup> D Q!r <sup>C</sup>i!Q<sup>i</sup> because the sine function is larger than unity for a complex argument. One can show that for any !i there will also be a corresponding solution with !i . The component with !i > 0 gives an amplification factor e!i <sup>t</sup> that grows exponentially in time. We cannot allow this and must therefore require <sup>C</sup> <sup>1</sup> as a *stability criterion*.

#### **Remark on the stability requirement**

For smoother wave components with longer wave lengths per length x, (2.89) can in theory be relaxed. However, small round-off errors are always present in a numerical solution and these vary arbitrarily from mesh point to mesh point and can be viewed as unavoidable noise with wavelength 2x. As explained, C >1 will for this very small noise lead to exponential growth of the shortest possible wave component in the mesh. This noise will therefore grow with time and destroy the whole solution.

#### **2.10.4 Numerical Dispersion Relation**

Equation (2.88) can be solved with respect to !Q:

$$
\tilde{\phi} = \frac{2}{\Delta t} \sin^{-1} \left( C \sin \left( \frac{k \Delta x}{2} \right) \right) . \tag{2.90}
$$

The relation between the numerical frequency !Q and the other parameters <sup>k</sup>, <sup>c</sup>, x, and t is called a *numerical dispersion relation*. Correspondingly, ! D kc is the *analytical dispersion relation*. In general, dispersion refers to the phenomenon where the wave velocity depends on the spatial frequency (k, or the wave length D 2=k) of the wave. Since the wave velocity is !=k D <sup>c</sup>, we realize that the analytical dispersion relation reflects the fact that there is no dispersion. However, in a numerical scheme we have dispersive waves where the wave velocity depends on k.

The special case <sup>C</sup> D <sup>1</sup> deserves attention since then the right-hand side of (2.90) reduces to

$$\frac{2}{\Delta t} \frac{k \Delta x}{2} = \frac{1}{\Delta t} \frac{\omega \Delta x}{c} = \frac{\omega}{C} = \omega \dots$$

That is, !Q D ! and the numerical solution is exact at all mesh points regardless of x and t! This implies that the numerical solution method is also an analytical solution method, at least for computing u at discrete points (the numerical method says nothing about the variation of u *between* the mesh points, and employing the common linear interpolation for extending the discrete solution gives a curve that in general deviates from the exact one).

For a closer examination of the error in the numerical dispersion relation when C <1, we can study !Q !, !=! Q , or the similar error measures in wave velocity: <sup>c</sup>Q <sup>c</sup> and c=c Q , where <sup>c</sup> D !=k and <sup>c</sup>Q D Q!=k. It appears that the most convenient expression to work with is c=c Q , since it can be written as a function of just two parameters:

$$\frac{\tilde{c}}{c} = \frac{1}{Cp} \sin^{-1} \left( C \sin p \right),$$

with <sup>p</sup> D kx=2 as a non-dimensional measure of the spatial frequency. In essence, p tells how many spatial mesh points we have per wave length in space for the wave component with frequency k (recall that the wave length is 2=k). That is, p reflects how well the spatial variation of the wave component is resolved in the mesh. Wave components with wave length less than 2x (2=k < 2x) are not visible in the mesh, so it does not make sense to have p > =2.

We may introduce the function r .C; p/ D Qc=c for further investigation of numerical errors in the wave velocity:

$$r(C, p) = \frac{1}{Cp} \sin^{-1} \left( C \sin p \right), \quad C \in (0, 1], \ p \in (0, \pi/2]. \tag{2.91}$$

This function is very well suited for plotting since it combines several parameters in the problem into a dependence on two dimensionless numbers, C and p.

Defining

def r(C, p): return 2/(C\*p)\*asin(C\*sin(p))

we can plot r .C; p/ as a function of p for various values of C, see Fig. 2.6. Note that the shortest waves have the most erroneous velocity, and that short waves move more slowly than they should.

**Fig. 2.6** The fractional error in the wave velocity for different Courant numbers

We can also easily make a Taylor series expansion in the discretization parameter p:

```
>>> import sympy as sym
>>> C, p = sym.symbols('C p')
>>> # Compute the 7 first terms around p=0 with no O() term
>>> rs = r(C, p).series(p, 0, 7).removeO()
>>> rs
p**6*(5*C**6/112 - C**4/16 + 13*C**2/720 - 1/5040) +
p**4*(3*C**4/40 - C**2/12 + 1/120) +
p**2*(C**2/6 - 1/6) + 1
>>> # Pick out the leading order term, but drop the constant 1
>>> rs_error_leading_order = (rs - 1).extract_leading_order(p)
>>> rs_error_leading_order
p**2*(C**2/6 - 1/6)
>>> # Turn the series expansion into a Python function
>>> rs_pyfunc = lambdify([C, p], rs, modules='numpy')
>>> # Check: rs_pyfunc is exact (=1) for C=1
>>> rs_pyfunc(1, 0.1)
1.0
```
Note that without the .removeO() call the series gets an O(x\*\*7) term that makes it impossible to convert the series to a Python function (for, e.g., plotting).

From the rs\_error\_leading\_order expression above, we see that the leading order term in the error of this series expansion is

$$\frac{1}{6}\left(\frac{k\Delta x}{2}\right)^2(C^2-1) = \frac{k^2}{24}\left(c^2\Delta t^2 - \Delta x^2\right),\tag{2.92}$$

pointing to an error *<sup>O</sup>*.t 2; x2/, which is compatible with the errors in the difference approximations (DtDtu and DxDxu).

We can do more with a series expansion, e.g., factor it to see how the factor <sup>C</sup> <sup>1</sup> plays a significant role. To this end, we make a list of the terms, factor each term, and then sum the terms:

```
>>> rs = r(C, p).series(p, 0, 4).removeO().as_ordered_terms()
>>> rs
[1, C**2*p**2/6 - p**2/6,
 3*C**4*p**4/40 - C**2*p**4/12 + p**4/120,
 5*C**6*p**6/112 - C**4*p**6/16 + 13*C**2*p**6/720 - p**6/5040]
>>> rs = [factor(t) for t in rs]
>>> rs
[1, p**2*(C - 1)*(C + 1)/6,
 p**4*(C - 1)*(C + 1)*(3*C - 1)*(3*C + 1)/120,
 p**6*(C - 1)*(C + 1)*(225*C**4 - 90*C**2 + 1)/5040]
>>> rs = sum(rs) # Python's sum function sums the list
>>> rs
p**6*(C - 1)*(C + 1)*(225*C**4 - 90*C**2 + 1)/5040 +
p**4*(C - 1)*(C + 1)*(3*C - 1)*(3*C + 1)/120 +
p**2*(C - 1)*(C + 1)/6 + 1
```
We see from the last expression that <sup>C</sup> D <sup>1</sup> makes all the terms in rs vanish. Since we already know that the numerical solution is exact for <sup>C</sup> D <sup>1</sup>, the remaining terms in the Taylor series expansion will also contain factors of <sup>C</sup> <sup>1</sup> and cancel for <sup>C</sup> D <sup>1</sup>.

#### **2.10.5 Extending the Analysis to 2D and 3D**

The typical analytical solution of a 2D wave equation

$$
u\_{ll} = c^2 (
u\_{xx} + 
u\_{yy}),$$

is a wave traveling in the direction of k D kxi Ckyj , where i and j are unit vectors in the <sup>x</sup> and <sup>y</sup> directions, respectively (i should not be confused with <sup>i</sup> D p<sup>1</sup> here). Such a wave can be expressed by

$$u(\mathbf{x}, \mathbf{y}, t) = \mathbf{g}(k\_x \mathbf{x} + k\_y \mathbf{y} - kct),$$

for some twice differentiable function <sup>g</sup>, or with ! D kc, <sup>k</sup> D jkj:

$$u(\mathbf{x}, \mathbf{y}, t) = \mathbf{g} \left( k\_x \mathbf{x} + k\_y \mathbf{y} - \alpha t \right).$$

We can, in particular, build a solution by adding complex Fourier components of the form

$$e^{\left(i\left(k\_{\underline{x}}\underline{x} + k\_{\underline{y}}\underline{y} - \omega t\right)\right)}\\_\cdot$$

A discrete 2D wave equation can be written as

$$\left[D\_l D\_l u = c^2 (D\_x D\_x u + D\_y D\_y u)\right]\_{q,r}^n. \tag{2.93}$$

This equation admits a Fourier component

$$u\_{q,r}^n = e^{\left(i(k\_\times q \Delta x + k\_\circ r \Delta y - \tilde{\alpha} n \Delta t)\right)},\tag{2.94}$$

as solution. Letting the operators DtDt , DxDx, and DyDy act on u<sup>n</sup> q;r from (2.94) transforms (2.93) to

$$\frac{4}{\Delta t^2} \sin^2 \left(\frac{\tilde{w}\Delta t}{2}\right) = c^2 \frac{4}{\Delta x^2} \sin^2 \left(\frac{k\_x \Delta x}{2}\right) + c^2 \frac{4}{\Delta y^2} \sin^2 \left(\frac{k\_y \Delta y}{2}\right) \tag{2.95}$$

or

$$
\sin^2\left(\frac{\tilde{\omega}\,\Delta t}{2}\right) = C\_x^2 \sin^2 p\_x + C\_y^2 \sin^2 p\_y,\tag{2.96}
$$

where we have eliminated the factor 4 and introduced the symbols

$$C\_{\chi} = \frac{c \,\Delta t}{\Delta x}, \quad C\_{\chi} = \frac{c \,\Delta t}{\Delta y}, \quad p\_{\chi} = \frac{k\_x \Delta x}{2}, \quad p\_{\chi} = \frac{k\_y \Delta y}{2}.$$

For a real-valued !Q the right-hand side must be less than or equal to unity in absolute value, requiring in general that

$$\text{C}\_x^2 + \text{C}\_y^2 \le 1\,\text{.}\tag{2.97}$$

This gives the stability criterion, more commonly expressed directly in an inequality for the time step:

$$
\Delta t \le \frac{1}{c} \left( \frac{1}{\Delta x^2} + \frac{1}{\Delta y^2} \right)^{-1/2}. \tag{2.98}
$$

A similar, straightforward analysis for the 3D case leads to

$$
\Delta t \le \frac{1}{c} \left( \frac{1}{\Delta x^2} + \frac{1}{\Delta y^2} + \frac{1}{\Delta z^2} \right)^{-1/2}. \tag{2.99}
$$

In the case of a variable coefficient <sup>c</sup><sup>2</sup> <sup>D</sup> <sup>c</sup>2.x/, we must use the worst-case value

$$\bar{c} = \sqrt{\max\_{\mathbf{x} \in \mathcal{Q}} c^2(\mathbf{x})} \tag{2.100}$$

in the stability criteria. Often, especially in the variable wave velocity case, it is wise to introduce a safety factor <sup>ˇ</sup> 2 .0; 1 too:

$$
\Delta t \le \beta \frac{1}{\bar{c}} \left( \frac{1}{\Delta x^2} + \frac{1}{\Delta y^2} + \frac{1}{\Delta z^2} \right)^{-1/2} \,. \tag{2.101}
$$

The exact numerical dispersion relations in 2D and 3D becomes, for constant c,

$$
\tilde{\omega} = \frac{2}{\Delta t} \sin^{-1} \left( \left( C\_x^2 \sin^2 p\_x + C\_y^2 \sin^2 p\_y \right)^{\frac{1}{2}} \right), \tag{2.102}
$$

$$\tilde{\omega} = \frac{2}{\Delta t} \sin^{-1} \left( \left( C\_x^2 \sin^2 p\_x + C\_y^2 \sin^2 p\_y + C\_z^2 \sin^2 p\_z \right)^{\frac{1}{2}} \right) . \tag{2.103}$$

:

We can visualize the numerical dispersion error in 2D much like we did in 1D. To this end, we need to reduce the number of parameters in !Q. The direction of the wave is parameterized by the polar angle -, which means that

$$k\_{\times} = k \sin \theta, \quad k\_{\circ} = k \cos \theta$$

A simplification is to set x <sup>D</sup> y <sup>D</sup> <sup>h</sup>. Then Cx <sup>D</sup> Cy <sup>D</sup> ct=h, which we call C. Also,

$$p\_{\ddagger} = \frac{1}{2}kh\cos\theta, \quad p\_{\circ} = \frac{1}{2}kh\sin\theta \ .$$

The numerical frequency !Q is now a function of three parameters:


We want to visualize the error in the numerical frequency. To avoid having t as a free parameter in !Q, we work with c=c Q D Q!=.kc/. The coefficient in front of the sin<sup>1</sup> factor is then

$$\frac{2}{kc\Delta t} = \frac{2}{2kc\Delta th/h} = \frac{1}{Ckh} = \frac{2}{Cp}.$$

and

$$\frac{\tilde{c}}{c} = \frac{2}{C\_P} \sin^{-1} \left( C \left( \sin^2(p \cos \theta) + \sin^2(p \sin \theta) \right)^{\frac{1}{2}} \right) \dots$$

We want to visualize this quantity as a function of p and for some values of <sup>C</sup> <sup>1</sup>. It is instructive to make color contour plots of <sup>1</sup> Qc=c in *polar coordinates* with as the angular coordinate and p as the radial coordinate.

The stability criterion (2.97) becomes <sup>C</sup> <sup>C</sup>max <sup>D</sup> 1=p<sup>2</sup> in the present 2D case with the <sup>C</sup> defined above. Let us plot <sup>1</sup> Qc=c in polar coordinates for Cmax; 0:9Cmax; 0:5Cmax; 0:2Cmax. The program below does the somewhat tricky work in Matplotlib, and the result appears in Fig. 2.7. From the figure we clearly see that the maximum C value gives the best results, and that waves whose propagation direction makes an angle of 45 degrees with an axis are the most accurate.

```
def dispersion_relation_2D(p, theta, C):
    arg = C*sqrt(sin(p*cos(theta))**2 +
                 sin(p*sin(theta))**2)
    c_frac = 2./(C*p)*arcsin(arg)
    return c_frac
import numpy as np
from numpy import \
     cos, sin, arcsin, sqrt, pi # for nicer math formulas
r = p = np.linspace(0.001, pi/2, 101)
theta = np.linspace(0, 2*pi, 51)
r, theta = np.meshgrid(r, theta)
# Make 2x2 filled contour plots for 4 values of C
import matplotlib.pyplot as plt
C_max = 1/sqrt(2)
C = [[C_max, 0.9*C_max], [0.5*C_max, 0.2*C_max]]
fix, axes = plt.subplots(2, 2, subplot_kw=dict(polar=True))
for row in range(2):
    for column in range(2):
        error =1- dispersion_relation_2D(
            p, theta, C[row][column])
        print error.min(), error.max()
        # use vmin=error.min(), vmax=error.max()
        cax = axes[row][column].contourf(
            theta, r, error, 50, vmin=-1, vmax=-0.28)
        axes[row][column].set_xticks([])
        axes[row][column].set_yticks([])
# Add colorbar to the last plot
cbar = plt.colorbar(cax)
cbar.ax.set_ylabel('error in wave velocity')
plt.savefig('disprel2D.png'); plt.savefig('disprel2D.pdf')
```
**Fig. 2.7** Error in numerical dispersion in 2D

plt.show()

#### **2.11 Finite Difference Methods for 2D and 3D Wave Equations**

A natural next step is to consider extensions of the methods for various variants of the one-dimensional wave equation to two-dimensional (2D) and three-dimensional (3D) versions of the wave equation.

#### **2.11.1 Multi-Dimensional Wave Equations**

The general wave equation in d space dimensions, with constant wave velocity c, can be written in the compact form

$$\frac{\partial^2 u}{\partial t^2} = c^2 \nabla^2 u \text{ for } \mathbf{x} \in \mathfrak{Q} \subset \mathbb{R}^d, \quad t \in (0, T], \tag{2.104}$$

where

$$
\nabla^2 u = \frac{\partial^2 u}{\partial x^2} + \frac{\partial^2 u}{\partial y^2},
$$

in a 2D problem (<sup>d</sup> D <sup>2</sup>) and

$$
\nabla^2 u = \frac{\partial^2 u}{\partial x^2} + \frac{\partial^2 u}{\partial y^2} + \frac{\partial^2 u}{\partial z^2},
$$

in three space dimensions (<sup>d</sup> D <sup>3</sup>).

Many applications involve variable coefficients, and the general wave equation in d dimensions is in this case written as

$$
\varrho \frac{\partial^2 u}{\partial t^2} = \nabla \cdot (q \nabla u) + f \text{ for } \mathbf{x} \in \mathfrak{Q} \subset \mathbb{R}^d, \quad t \in (0, T], \tag{2.105}
$$

which in, e.g., 2D becomes

$$\varrho(\mathbf{x}, \mathbf{y}) \frac{\partial^2 u}{\partial t^2} = \frac{\partial}{\partial \mathbf{x}} \left( q(\mathbf{x}, \mathbf{y}) \frac{\partial u}{\partial \mathbf{x}} \right) + \frac{\partial}{\partial \mathbf{y}} \left( q(\mathbf{x}, \mathbf{y}) \frac{\partial u}{\partial \mathbf{y}} \right) + f(\mathbf{x}, \mathbf{y}, t) \,. \tag{2.106}$$

To save some writing and space we may use the index notation, where subscript t, x, or y means differentiation with respect to that coordinate. For example,

$$\begin{aligned} \frac{\partial^2 u}{\partial t^2} &= u\_{II}, \\ \frac{\partial}{\partial \mathbf{y}} \left( q(\mathbf{x}, \mathbf{y}) \frac{\partial u}{\partial \mathbf{y}} \right) &= (q u\_{\mathbf{y}})\_{\mathbf{y}} \dots \end{aligned}$$

These comments extend straightforwardly to 3D, which means that the 3D versions of the two wave PDEs, with and without variable coefficients, can be stated as

$$u\_{II} = c^2(u\_{xx} + u\_{yy} + u\_{zz}) + f,\tag{2.107}$$

$$
\varrho u\_{ll} = (qu\_x)\_x + (qu\_y)\_y + (qu\_z)\_z + f \,\,. \tag{2.108}
$$

At *each point* of the boundary @˝ (of ˝) we need *one* boundary condition involving the unknown u. The boundary conditions are of three principal types:


All the listed wave equations with *second-order* derivatives in time need *two* initial conditions:

1. <sup>u</sup> D <sup>I</sup> , 2. ut <sup>D</sup> <sup>V</sup> .

#### **2.11.2 Mesh**

We introduce a mesh in time and in space. The mesh in time consists of time points

$$t\_0 = 0 < t\_1 < \cdots < t\_{N\_I},$$

normally, for wave equation problems, with a constant spacing t <sup>D</sup> tnC<sup>1</sup> tn, <sup>n</sup> 2 *<sup>I</sup>* t .

Finite difference methods are easy to implement on simple rectangle- or boxshaped *spatial domains*. More complicated shapes of the spatial domain require substantially more advanced techniques and implementational efforts (and a finite element method is usually a more convenient approach). On a rectangle- or box-shaped domain, mesh points are introduced separately in the various space directions:

$$\begin{aligned} &x\_0 < x\_1 < \dots < x\_{N\_\mathcal{X}} \text{ in the } x \text{ direction,} \\ &y\_0 < y\_1 < \dots < y\_{N\_\mathcal{Y}} \text{ in the } y \text{ direction,} \\ &z\_0 < z\_1 < \dots < z\_{N\_\mathcal{Z}} \text{ in the } z \text{ direction.} \end{aligned}$$

We can write a general mesh point as .xi ; yj ; zk; tn/, with <sup>i</sup> <sup>2</sup> *<sup>I</sup>*<sup>x</sup> , <sup>j</sup> <sup>2</sup> *<sup>I</sup>*<sup>y</sup> , <sup>k</sup> <sup>2</sup> *<sup>I</sup>*<sup>z</sup> , and <sup>n</sup> <sup>2</sup> *<sup>I</sup>*<sup>t</sup> .

It is a very common choice to use constant mesh spacings: x <sup>D</sup> xiC<sup>1</sup> xi , <sup>i</sup> 2 *<sup>I</sup>* <sup>x</sup> , y <sup>D</sup> yj <sup>C</sup><sup>1</sup> yj , <sup>j</sup> <sup>2</sup> *<sup>I</sup>* <sup>y</sup> , and z <sup>D</sup> zkC<sup>1</sup> zk, <sup>k</sup> <sup>2</sup> *<sup>I</sup>* <sup>z</sup> . With equal mesh spacings one often introduces <sup>h</sup> D x D y D z.

The unknown u at mesh point .xi ; yj ; zk; tn/ is denoted by u<sup>n</sup> i;j;k. In 2D problems we just skip the <sup>z</sup> coordinate (by assuming no variation in that direction: @=@z D <sup>0</sup>) and write u<sup>n</sup> i;j .

#### **2.11.3 Discretization**

Two- and three-dimensional wave equations are easily discretized by assembling building blocks for discretization of 1D wave equations, because the multidimensional versions just contain terms of the same type as those in 1D.

**Discretizing the PDEs** Equation (2.107) can be discretized as

$$\left[D\_l D\_l u = c^2 \left(D\_x D\_x u + D\_y D\_y u + D\_z D\_z u\right) + f\right]\_{l,j,k}^n. \tag{2.109}$$

A 2D version might be instructive to write out in detail:

$$\left[D\_l D\_l \mu = c^2 (D\_\lambda D\_\lambda \mu + D\_\lambda D\_\lambda \mu) + f\right]\_{l,j}^n,$$

which becomes

$$\begin{split} \frac{u\_{i,j}^{n+1} - 2u\_{i,j}^n + u\_{i,j}^{n-1}}{\Delta t^2} &= c^2 \frac{u\_{i+1,j}^n - 2u\_{i,j}^n + u\_{i-1,j}^n}{\Delta x^2} \\ &+ c^2 \frac{u\_{i,j+1}^n - 2u\_{i,j}^n + u\_{i,j-1}^n}{\Delta y^2} + f\_{i,j}^n .\end{split}$$

Assuming, as usual, that all values at time levels <sup>n</sup> and <sup>n</sup> <sup>1</sup> are known, we can solve for the only unknown u<sup>n</sup>C<sup>1</sup> i;j . The result can be compactly written as

$$\Delta u\_{i,j}^{n+1} = 2u\_{i,j}^n + u\_{i,j}^{n-1} + c^2 \Delta t^2 [D\_x D\_x u + D\_y D\_y u]\_{i,j}^n. \tag{2.110}$$

As in the 1D case, we need to develop a special formula for u<sup>1</sup> i;j where we combine the general scheme for u<sup>n</sup>C<sup>1</sup> i;j , when <sup>n</sup> <sup>D</sup> <sup>0</sup>, with the discretization of the initial condition:

$$\left[D\_{2l}u = V\right]\_{i,j}^0 \quad \Rightarrow \quad u\_{i,j}^{-1} = u\_{i,j}^1 - 2\Delta t \, V\_{i,j} \dots$$

The result becomes, in compact form,

$$u\_{i,j}^1 = u\_{i,j}^0 - 2\Delta V\_{i,j} + \frac{1}{2}c^2 \Delta t^2 [D\_x D\_x u + D\_y D\_y u]\_{i,j}^0 \,. \tag{2.111}$$

The PDE (2.108) with variable coefficients is discretized term by term using the corresponding elements from the 1D case:

$$\left[\varrho D\_{l}D\_{l}\mathfrak{u} = (D\_{x}\overline{q}^{x}D\_{x}\mathfrak{u} + D\_{\overline{\gamma}}\overline{q}^{\overline{\gamma}}D\_{\overline{\gamma}}\mathfrak{u} + D\_{z}\overline{q}^{z}D\_{z}\mathfrak{u}) + f\right]\_{l,j,k}^{n}.\tag{2.112}$$

When written out and solved for the unknown u<sup>n</sup>C<sup>1</sup> i;j;k, one gets the scheme

$$\begin{split} u\_{i,j,k}^{n+1} &= -u\_{i,j,k}^{n+1} + 2u\_{i,j,k}^{n} \\ &\quad + \frac{1}{\varrho\_{i,j,k}} \frac{1}{\Delta x^{2}} \left( \frac{1}{2} (q\_{i,j,k} + q\_{i+1,j,k}) (u\_{i+1,j,k}^{n} - u\_{i,j,k}^{n}) \right) \\ &\quad - \frac{1}{2} (q\_{i-1,j,k} + q\_{i,j,k}) (u\_{i,j,k}^{n} - u\_{i-1,j,k}^{n}) \Bigg) \\ &\quad + \frac{1}{\varrho\_{i,j,k}} \frac{1}{\Delta y^{2}} \left( \frac{1}{2} (q\_{i,j,k} + q\_{i,j+1,k}) (u\_{i,j+1,k}^{n} - u\_{i,j,k}^{n}) \right) \\ &\quad - \frac{1}{2} (q\_{i,j-1,k} + q\_{i,j,k}) (u\_{i,j,k}^{n} - u\_{i,j-1,k}^{n}) \Bigg) \\ &\quad + \frac{1}{\varrho\_{i,j,k}} \frac{1}{\Delta z^{2}} \left( \frac{1}{2} (q\_{i,j,k} + q\_{i,j,k+1}) (u\_{i,j,k+1}^{n} - u\_{i,j,k}^{n}) \right) \\ &\quad - \frac{1}{2} (q\_{i,j,k-1} + q\_{i,j,k}) (u\_{i,j,k}^{n} - u\_{i,j,k-1}^{n}) \Bigg) \\ &\quad + \Delta t^{2} f\_{i,j,k}^{n} .\end{split}$$

Also here we need to develop a special formula for u<sup>1</sup> i;j;k by combining the scheme for <sup>n</sup> D <sup>0</sup> with the discrete initial condition, which is just a matter of inserting u<sup>1</sup> i;j;k <sup>D</sup> <sup>u</sup><sup>1</sup> i;j;k 2tVi;j;k in the scheme and solving for <sup>u</sup><sup>1</sup> i;j;k.

**Handling boundary conditions where** u **is known** The schemes listed above are valid for the internal points in the mesh. After updating these, we need to visit all the mesh points at the boundaries and set the prescribed u value.

**Discretizing the Neumann condition** The condition @u=@n D <sup>0</sup> was implemented in 1D by discretizing it with a D2xu centered difference, followed by eliminating the fictitious u point outside the mesh by using the general scheme at the boundary point. Alternatively, one can introduce ghost cells and update a ghost value for use in the Neumann condition. Exactly the same ideas are reused in multiple dimensions.

Consider the condition @u=@n D <sup>0</sup> at a boundary <sup>y</sup> D <sup>0</sup> of a rectangular domain Œ0; Lx -Œ0; Ly in 2D. The normal direction is then in <sup>y</sup> direction, so

$$
\frac{\partial u}{\partial n} = -\frac{\partial u}{\partial y},
$$

and we set

$$[-D\_{2y}u = 0]\_{i,0}^{n} \quad \Rightarrow \quad \frac{u\_{i,1}^{n} - u\_{i,-1}^{n}}{2\Delta y} = 0 \dots$$

From this it follows that u<sup>n</sup> i;<sup>1</sup> <sup>D</sup> <sup>u</sup><sup>n</sup> i;1. The discretized PDE at the boundary point .i; 0/ reads

$$\frac{u\_{i,0}^{n+1} - 2u\_{i,0}^n + u\_{i,0}^{n-1}}{\Delta t^2} = c^2 \frac{u\_{i+1,0}^n - 2u\_{i,0}^n + u\_{i-1,0}^n}{\Delta x^2} + c^2 \frac{u\_{i,1}^n - 2u\_{i,0}^n + u\_{i,-1}^n}{\Delta y^2} + f\_{i,j}^n \dots$$

We can then just insert u<sup>n</sup> i;1 for u<sup>n</sup> i;<sup>1</sup> in this equation and solve for the boundary value u<sup>n</sup>C<sup>1</sup> i;0 , just as was done in 1D.

From these calculations, we see a pattern: the general scheme applies at the boundary <sup>j</sup> D <sup>0</sup> too if we just replace <sup>j</sup> <sup>1</sup> by <sup>j</sup> C <sup>1</sup>. Such a pattern is particularly useful for implementations. The details follow from the explained 1D case in Sect. 2.6.3.

The alternative approach to eliminating fictitious values outside the mesh is to have u<sup>n</sup> i;<sup>1</sup> available as a ghost value. The mesh is extended with one extra line (2D) or plane (3D) of ghost cells at a Neumann boundary. In the present example it means that we need a line with ghost cells below the y axis. The ghost values must be updated according to u<sup>n</sup>C<sup>1</sup> i;<sup>1</sup> <sup>D</sup> <sup>u</sup><sup>n</sup>C<sup>1</sup> i;1 .

#### **2.12 Implementation**

We shall now describe in detail various Python implementations for solving a standard 2D, linear wave equation with constant wave velocity and <sup>u</sup> D <sup>0</sup> on the boundary. The wave equation is to be solved in the space-time domain ˝ - .0; T , where ˝ D .0; Lx/ - .0; Ly/ is a rectangular spatial domain. More precisely, the complete initial-boundary value problem is defined by

$$u\_{ll} = c^2(u\_{xx} + u\_{yy}) + f(\mathbf{x}, \mathbf{y}, t), \qquad (\mathbf{x}, \mathbf{y}) \in \mathcal{Q}, \ t \in (0, T], \quad (2.113)$$

$$u(\mathbf{x}, \mathbf{y}, 0) = I(\mathbf{x}, \mathbf{y}), \qquad \qquad (\mathbf{x}, \mathbf{y}) \in \mathcal{Q}, \tag{2.114}$$

$$u\_l(\mathbf{x}, \mathbf{y}, 0) = V(\mathbf{x}, \mathbf{y}), \qquad \qquad (\mathbf{x}, \mathbf{y}) \in \mathcal{Q}, \tag{2.115}$$

$$u = 0, \qquad \qquad (\mathbf{x}, \mathbf{y}) \in \partial \mathcal{Q}, \ t \in (0, T], \tag{2.116}$$

where @˝ is the boundary of ˝, in this case the four sides of the rectangle ˝ D Œ0; Lx -Œ0; Ly: <sup>x</sup> D <sup>0</sup>, <sup>x</sup> D Lx, <sup>y</sup> D <sup>0</sup>, and <sup>y</sup> D Ly.

The PDE is discretized as

$$\left[D\_l D\_l \mu = c^2 (D\_\lambda D\_\lambda \mu + D\_\lambda D\_\lambda \mu) + f\right]\_{l,j}^n,$$

which leads to an explicit updating formula to be implemented in a program:

$$\begin{split} u\_{i,j}^{n+1} &= -u\_{i,j}^{n-1} + 2u\_{i,j}^{n} \\ &+ C\_x^2 (u\_{i+1,j}^{n} - 2u\_{i,j}^{n} + u\_{i-1,j}^{n}) + C\_y^2 (u\_{i,j+1}^{n} - 2u\_{i,j}^{n} + u\_{i,j-1}^{n}) \\ &+ \Delta t^2 f\_{i,j}^{n}, \\ \end{split} \tag{2.117}$$

for all interior mesh points <sup>i</sup> 2 *<sup>I</sup>*<sup>i</sup> <sup>x</sup> and <sup>j</sup> <sup>2</sup> *<sup>I</sup>*<sup>i</sup> <sup>y</sup> , for <sup>n</sup> <sup>2</sup> *<sup>I</sup>*<sup>C</sup> <sup>t</sup> . The constants Cx and Cy are defined as

$$C\_{\chi} = c \frac{\Delta t}{\Delta x}, \quad C\_{\mathcal{Y}} = c \frac{\Delta t}{\Delta \mathcal{Y}} \,.$$

At the boundary, we simply set u<sup>n</sup>C<sup>1</sup> i;j <sup>D</sup> <sup>0</sup> for <sup>i</sup> <sup>D</sup> <sup>0</sup>, <sup>j</sup> <sup>D</sup> 0; : : : ; Ny; <sup>i</sup> <sup>D</sup> Nx, <sup>j</sup> D 0; : : : ; Ny; <sup>j</sup> D <sup>0</sup>, <sup>i</sup> D 0; : : : ; Nx; and <sup>j</sup> D Ny, <sup>i</sup> D 0; : : : ; Nx. For the first step, <sup>n</sup> D <sup>0</sup>, (2.117) is combined with the discretization of the initial condition ut <sup>D</sup> <sup>V</sup> , ŒD2tu <sup>D</sup> V <sup>0</sup> i;j to obtain a special formula for u<sup>1</sup> i;j at the interior mesh points:

$$\begin{split} u\_{i,j}^1 &= u\_{i,j}^0 + \Delta t V\_{i,j} \\ &+ \frac{1}{2} C\_x^2 (u\_{i+1,j}^0 - 2u\_{i,j}^0 + u\_{i-1,j}^0) + \frac{1}{2} C\_y^2 (u\_{i,j+1}^0 - 2u\_{i,j}^0 + u\_{i,j-1}^0) \\ &+ \frac{1}{2} \Delta t^2 f\_{i,j}^n. \end{split} \tag{2.118}$$

The algorithm is very similar to the one in 1D:


#### **2.12.1 Scalar Computations**

The solver function for a 2D case with constant wave velocity and boundary condition <sup>u</sup> D <sup>0</sup> is analogous to the 1D case with similar parameter values (see wave1D\_u0.py), apart from a few necessary extensions. The code is found in the program wave2D\_u0.py.

**Domain and mesh** The spatial domain is now Œ0; Lx - Œ0; Ly, specified by the arguments Lx and Ly. Similarly, the number of mesh points in the x and y directions, Nx and Ny, become the arguments Nx and Ny. In multi-dimensional problems it makes less sense to specify a Courant number since the wave velocity is a vector and mesh spacings may differ in the various spatial directions. We therefore give t explicitly. The signature of the solver function is then

```
def solver(I, V, f, c, Lx, Ly, Nx, Ny, dt, T,
           user_action=None, version='scalar'):
```
Key parameters used in the calculations are created as

```
x = linspace(0, Lx, Nx+1) # mesh points in x dir
y = linspace(0, Ly, Ny+1) # mesh points in y dir
dx = x[1] - x[0]
dy = y[1] - y[0]
Nt = int(round(T/float(dt)))
t = linspace(0, N*dt, N+1) # mesh points in time
Cx2 = (c*dt/dx)**2; Cy2 = (c*dt/dy)**2 # help variables
dt2 = dt**2
```
**Solution arrays** We store u<sup>n</sup>C<sup>1</sup> i;j , u<sup>n</sup> i;j , and u<sup>n</sup><sup>1</sup> i;j in three two-dimensional arrays,

```
u = zeros((Nx+1,Ny+1)) # solution array
u_n = [zeros((Nx+1,Ny+1)), zeros((Nx+1,Ny+1))] # t-dt, t-2*dt
```
where u<sup>n</sup>C<sup>1</sup> i;j corresponds to u[i,j], u<sup>n</sup> i;j to u\_n[i,j], and u<sup>n</sup><sup>1</sup> i;j to u\_nm1[i,j].

**Index sets** It is also convenient to introduce the index sets (cf. Sect. 2.6.4)

```
Ix = range(0, u.shape[0])
Iy = range(0, u.shape[1])
It = range(0, t.shape[0])
```
**Computing the solution** Inserting the initial condition I in u\_n and making a callback to the user in terms of the user\_action function is a straightforward generalization of the 1D code from Sect. 2.1.6:

```
for i in Ix:
    for j in Iy:
        u_n[i,j] = I(x[i], y[j])
if user_action is not None:
    user_action(u_n, x, xv, y, yv, t, 0)
```
The user\_action function has additional arguments compared to the 1D case. The arguments xv and yv will be commented upon in Sect. 2.12.2.

The key finite difference formula (2.110) for updating the solution at a time level is implemented in a separate function as

```
def advance_scalar(u, u_n, u_nm1, f, x, y, t, n, Cx2, Cy2, dt2,
                   V=None, step1=False):
    Ix = range(0, u.shape[0]); Iy = range(0, u.shape[1])
    if step1:
        dt = sqrt(dt2) # save
        Cx2 = 0.5*Cx2; Cy2 = 0.5*Cy2; dt2 = 0.5*dt2 # redefine
        D1 = 1; D2 = 0
    else:
        D1 = 2; D2 = 1
    for i in Ix[1:-1]:
        for j in Iy[1:-1]:
            u_xx = u_n[i-1,j] - 2*u_n[i,j] + u_n[i+1,j]
            u_yy = u_n[i,j-1] - 2*u_n[i,j] + u_n[i,j+1]
            u[i,j] = D1*u_n[i,j] - D2*u_nm1[i,j]+\
                     Cx2*u_xx + Cy2*u_yy + dt2*f(x[i], y[j], t[n])
            if step1:
                u[i,j] += dt*V(x[i], y[j])
```

```
# Boundary condition u=0
j = Iy[0]
for i in Ix: u[i,j]=0
j = Iy[-1]
for i in Ix: u[i,j]=0
i = Ix[0]
for j in Iy: u[i,j]=0
i = Ix[-1]
for j in Iy: u[i,j]=0
return u
```
The step1 variable has been introduced to allow the formula to be reused for the first step, computing u<sup>1</sup> i;j :

```
u = advance_scalar(u, u_n, f, x, y, t,
                   n, Cx2, Cy2, dt, V, step1=True)
```
Below, we will make many alternative implementations of the advance\_scalar function to speed up the code since most of the CPU time in simulations is spent in this function.

**Remark: How to use the solution**

The solver function in the wave2D\_u0.py code updates arrays for the next time step by switching references as described in Sect. 2.4.5. Any use of u on the user's side is assumed to take place in the user action function. However, should the code be changed such that u is returned and used as solution, have in mind that you must return u\_n after the time limit, otherwise a return u will actually return u\_nm1 (due to the switching of array indices in the loop)!

#### **2.12.2 Vectorized Computations**

The scalar code above turns out to be extremely slow for large 2D meshes, and probably useless in 3D beyond debugging of small test cases. Vectorization is therefore a must for multi-dimensional finite difference computations in Python. For example, with a mesh consisting of <sup>30</sup> - 30 cells, vectorization brings down the CPU time by a factor of 70 (!). Equally important, vectorized code can also easily be parallelized to take (usually) optimal advantage of parallel computer platforms.

In the vectorized case, we must be able to evaluate user-given functions like I.x; y/ and f .x; y; t/ for the entire mesh in one operation (without loops). These user-given functions are provided as Python functions I(x,y) and f(x,y,t), respectively. Having the one-dimensional coordinate arrays x and y is not sufficient when calling I and f in a vectorized way. We must extend x and y to their vectorized versions xv and yv:

```
from numpy import newaxis
xv = x[:,newaxis]
yv = y[newaxis,:]
```

```
# or
xv = x.reshape((x.size, 1))
yv = y.reshape((1, y.size))
```
This is a standard required technique when evaluating functions over a 2D mesh, say sin(xv)\*cos(xv), which then gives a result with shape (Nx+1,Ny+1). Calling I(xv, yv) and f(xv, yv, t[n]) will now return I and f values for the entire set of mesh points.

With the xv and yv arrays for vectorized computing, setting the initial condition is just a matter of

u\_n[:,:] = I(xv, yv)

One could also have written u\_n = I(xv, yv) and let u\_n point to a new object, but vectorized operations often make use of direct insertion in the original array through u\_n[:,:], because sometimes not all of the array is to be filled by such a function evaluation. This is the case with the computational scheme for u<sup>n</sup>C<sup>1</sup> i;j :

```
def advance_vectorized(u, u_n, u_nm1, f_a, Cx2, Cy2, dt2,
                       V=None, step1=False):
    if step1:
        dt = np.sqrt(dt2) # save
        Cx2 = 0.5*Cx2; Cy2 = 0.5*Cy2; dt2 = 0.5*dt2 # redefine
        D1 = 1; D2 = 0
    else:
        D1 = 2; D2 = 1
    u_xx = u_n[:-2,1:-1] - 2*u_n[1:-1,1:-1] + u_n[2:,1:-1]
    u_yy = u_n[1:-1,:-2] - 2*u_n[1:-1,1:-1] + u_n[1:-1,2:]
    u[1:-1,1:-1] = D1*u_n[1:-1,1:-1] - D2*u_nm1[1:-1,1:-1] + \
                   Cx2*u_xx + Cy2*u_yy + dt2*f_a[1:-1,1:-1]
    if step1:
        u[1:-1,1:-1] += dt*V[1:-1, 1:-1]
    # Boundary condition u=0
    j = 0
    u[:,j]=0
    j = u.shape[1]-1
    u[:,j]=0
    i = 0
    u[i,:] = 0
    i = u.shape[0]-1
    u[i,:] = 0
    return u
```
Array slices in 2D are more complicated to understand than those in 1D, but the logic from 1D applies to each dimension separately. For example, when doing un i;j <sup>u</sup><sup>n</sup> <sup>i</sup>1;j for <sup>i</sup> <sup>2</sup> *<sup>I</sup>*<sup>C</sup> <sup>x</sup> , we just keep j constant and make a slice in the first index: u\_n[1:,j] - u\_n[:-1,j], exactly as in 1D. The 1: slice specifies all the indices <sup>i</sup> <sup>D</sup> 1; 2; : : : ; Nx (up to the last valid index), while :-1 specifies the relevant indices for the second term: 0; 1; : : : ; Nx <sup>1</sup> (up to, but not including the last index).

In the above code segment, the situation is slightly more complicated, because each displaced slice in one direction is accompanied by a 1:-1 slice in the other direction. The reason is that we only work with the internal points for the index that is kept constant in a difference.

The boundary conditions along the four sides make use of a slice consisting of all indices along a boundary:

u[: ,0] = 0 u[:,Ny]=0 u[0 ,:] = 0 u[Nx,:] = 0

In the vectorized update of u (above), the function f is first computed as an array over all mesh points:

f\_a = f(xv, yv, t[n])

We could, alternatively, have used the call f(xv, yv, t[n])[1:-1,1:-1] in the last term of the update statement, but other implementations in compiled languages benefit from having f available in an array rather than calling our Python function f(x,y,t) for every point.

Also in the advance\_vectorized function we have introduced a boolean step1 to reuse the formula for the first time step in the same way as we did with advance\_scalar. We refer to the solver function in wave2D\_u0.py for the details on how the overall algorithm is implemented.

The callback function now has the arguments u, x, xv, y, yv, t, n. The inclusion of xv and yv makes it easy to, e.g., compute an exact 2D solution in the callback function and compute errors, through an expression like u - u\_exact(xv, yv, t[n]).

#### **2.12.3 Verification**

**Testing a quadratic solution** The 1D solution from Sect. 2.2.4 can be generalized to multi-dimensions and provides a test case where the exact solution also fulfills the discrete equations, such that we know (to machine precision) what numbers the solver function should produce. In 2D we use the following generalization of (2.30):

$$u\_{\mathcal{C}}(\mathbf{x}, \mathbf{y}, t) = \mathbf{x}(L\_{\mathbf{x}} - \mathbf{x})\mathbf{y}(L\_{\mathbf{y}} - \mathbf{y})\left(1 + \frac{1}{2}t\right). \tag{2.119}$$

This solution fulfills the PDE problem if I.x; y/ D <sup>u</sup>e.x; y; 0/, <sup>V</sup> <sup>D</sup> <sup>1</sup> <sup>2</sup>ue.x; y; 0/, and <sup>f</sup> D 2c2.1 <sup>C</sup> <sup>1</sup> <sup>2</sup> t/.y.Ly y/ <sup>C</sup> x.Lx x//. To show that <sup>u</sup><sup>e</sup> also solves the discrete equations, we start with the general results ŒDtDt1<sup>n</sup> <sup>D</sup> <sup>0</sup>, ŒDtDt t<sup>n</sup> <sup>D</sup> <sup>0</sup>, and ŒDtDt <sup>t</sup> 2 <sup>D</sup> <sup>2</sup>, and use these to compute

$$\begin{aligned} [D\_{\boldsymbol{x}}D\_{\boldsymbol{x}}\boldsymbol{u}\_{\mathbf{e}}]\_{i,j}^{n} &= \left[\mathbf{y}(L\_{\boldsymbol{y}}-\mathbf{y})\left(1+\frac{1}{2}t\right)D\_{\boldsymbol{x}}D\_{\boldsymbol{x}}\mathbf{x}\left(L\_{\boldsymbol{x}}-\mathbf{x}\right)\right]\_{i,j}^{n} \\ &= \mathbf{y}\_{j}\left(L\_{\boldsymbol{y}}-\mathbf{y}\_{j}\right)\left(1+\frac{1}{2}t\_{n}\right)\left(-2\right). \end{aligned}$$

A similar calculation must be carried out for the ŒDyDyue n i;j and ŒDtDtue n i;j terms. One must also show that the quadratic solution fits the special formula for u1 i;j . The details are left as Exercise 2.16. The test\_quadratic function in the wave2D\_u0.py program implements this verification as a proper test function for the pytest and nose frameworks.

#### **2.12.4 Visualization**

Eventually, we are ready for a real application with our code! Look at the wave2D\_u0.py and the gaussian function. It starts with a Gaussian function to see how it propagates in a square with <sup>u</sup> D <sup>0</sup> on the boundaries:

```
def gaussian(plot_method=2, version='vectorized', save_plot=True):
    """
    Initial Gaussian bell in the middle of the domain.
    plot_method=1 applies mesh function,
    =2 means surf, =3 means Matplotlib, =4 means mayavi,
    =0 means no plot.
    """
    # Clean up plot files
    for name in glob('tmp_*.png'):
        os.remove(name)
    Lx = 10
    Ly = 10
    c = 1.0
    from numpy import exp
    def I(x, y):
        """Gaussian peak at (Lx/2, Ly/2)."""
        return exp(-0.5*(x-Lx/2.0)**2 - 0.5*(y-Ly/2.0)**2)
    def plot_u(u, x, xv, y, yv, t, n):
        """User action function for plotting."""
    ...
    Nx = 40; Ny = 40; T = 20
    dt, cpu = solver(I, None, None, c, Lx, Ly, Nx, Ny, -1, T,
                     user_action=plot_u, version=version)
```
**Matplotlib** We want to animate a 3D surface in Matplotlib, but this is a really slow process and not recommended, so we consider Matplotlib not an option as long as on-screen animation is desired. One can use the recipes for single shots of u, where it does produce high-quality 3D plots.

**Gnuplot** Let us look at different ways for visualization. We import SciTools as st and can access st.mesh and st.surf in Matplotlib or Gnuplot, but this is not supported except for the Gnuplot package, where it works really well (Fig. 2.8). Then we choose plot\_method=2 (or less relevant plot\_method=1) and force the

**Fig. 2.8** Snapshots of the surface plotted by Gnuplot

backend for SciTools to be Gnuplot (if you have the C package Gnuplot and the Gnuplot.py Python interface module installed):


It gives a nice visualization with lifted surface and contours beneath. Figure 2.8 shows four plots of u.

Video files can be made of the PNG frames:


It is wise to use a high frame rate – a low one will just skip many frames. There may also be considerable quality differences between the different formats.

**Movie 1** https://raw.githubusercontent.com/hplgit/fdm-book/master/doc/pub/book/html/ mov-wave/gnuplot/wave2D\_u0\_gaussian/movie25.mp4

**Mayavi** The best option for doing visualization of 2D and 3D scalar and vector fields in Python programs is Mayavi, which is an interface to the high-quality package VTK in C++. There is good online documentation and also an introduction in Chapter 5 of [10].

To obtain Mayavi on Ubuntu platforms you can write

For Mac OS X and Windows, we recommend using Anaconda. To obtain Mayavi for Anaconda you can write

```
Terminal
conda install mayavi
```
Mayavi has a MATLAB-like interface called mlab. We can do

```
import mayavi.mlab as plt
# or
from mayavi import mlab
```
and have plt (as usual) or mlab as a kind of MATLAB visualization access inside our program (just more powerful and with higher visual quality).

The official documentation of the mlab module is provided in two places, one for the basic functionality<sup>12</sup> and one for further functionality13. Basic figure handling<sup>14</sup> is very similar to the one we know from Matplotlib. Just as for Matplotlib, all plotting commands you do in mlab will go into the same figure, until you manually change to a new figure.

Back to our application, the following code for the user action function with plotting in Mayavi is relevant to add.

```
# Top of the file
try:
    import mayavi.mlab as mlab
except:
    # We don't have mayavi
    pass
def solver(...):
    ...
```
<sup>12</sup> http://docs.enthought.com/mayavi/mayavi/auto/mlab\_helper\_functions.html

<sup>13</sup> http://docs.enthought.com/mayavi/mayavi/auto/mlab\_other\_functions.html

<sup>14</sup> http://docs.enthought.com/mayavi/mayavi/auto/mlab\_figure.html

```
def gaussian(...):
    ...
    if plot_method == 3:
        from mpl_toolkits.mplot3d import axes3d
        import matplotlib.pyplot as plt
        from matplotlib import cm
        plt.ion()
        fig = plt.figure()
        u_surf = None
    def plot_u(u, x, xv, y, yv, t, n):
        """User action function for plotting."""
        if t[n] == 0:
            time.sleep(2)
        if plot_method == 1:
            # Works well with Gnuplot backend, not with Matplotlib
            st.mesh(x, y, u, title='t=%g' % t[n], zlim=[-1,1],
                    caxis=[-1,1])
        elif plot_method == 2:
            # Works well with Gnuplot backend, not with Matplotlib
            st.surfc(xv, yv, u, title='t=%g' % t[n], zlim=[-1, 1],
                  colorbar=True, colormap=st.hot(), caxis=[-1,1],
                  shading='flat')
        elif plot_method == 3:
            print 'Experimental 3D matplotlib...not recommended'
        elif plot_method == 4:
        # Mayavi visualization
            mlab.clf()
            extent1 = (0, 20, 0, 20,-2, 2)
            s = mlab.surf(x , y, u,
                          colormap='Blues',
                          warp_scale=5,extent=extent1)
            mlab.axes(s, color=(.7, .7, .7), extent=extent1,
                      ranges=(0, 10, 0, 10, -1, 1),
                      xlabel='', ylabel='', zlabel='',
                      x_axis_visibility=False,
                      z_axis_visibility=False)
            mlab.outline(s, color=(0.7, .7, .7), extent=extent1)
            mlab.text(6, -2.5, '', z=-4, width=0.14)
            mlab.colorbar(object=None, title=None,
                          orientation='horizontal',
                          nb_labels=None, nb_colors=None,
                          label_fmt=None)
            mlab.title('Gaussian t=%g' % t[n])
            mlab.view(142, -72, 50)
            f = mlab.gcf()
            camera = f.scene.camera
            camera.yaw(0)
        if plot_method > 0:
            time.sleep(0) # pause between frames
            if save_plot:
                filename = 'tmp_%04d.png' % n
        if plot_method == 4:
                    mlab.savefig(filename) # time consuming!
        elif plot_method in (1,2):
                    st.savefig(filename) # time consuming!
```
**Fig. 2.9** Plot with Mayavi

This is a point to get started – visualization is as always a very time-consuming and experimental discipline. With the PNG files we can use ffmpeg to create videos.

**Movie 2** https://github.com/hplgit/fdm-book/blob/master/doc/pub/book/html/mov-wave/ mayavi/wave2D\_u0\_gaussian/movie.mp4

#### **2.13 Exercises**

#### **Exercise 2.16: Check that a solution fulfills the discrete model**

Carry out all mathematical details to show that (2.119) is indeed a solution of the discrete model for a 2D wave equation with <sup>u</sup> D <sup>0</sup> on the boundary. One must check the boundary conditions, the initial conditions, the general discrete equation at a time level and the special version of this equation for the first time level. Filename: check\_quadratic\_solution.

#### **Project 2.17: Calculus with 2D mesh functions**

The goal of this project is to redo Project 2.6 with 2D mesh functions (fi;j ).

**Differentiation** The differentiation results in a discrete gradient function, which in the 2D case can be represented by a three-dimensional array df[d,i,j] where d represents the direction of the derivative, and i,j is a mesh point in 2D. Use centered differences for the derivative at inner points and one-sided forward or backward differences at the boundary points. Construct unit tests and write a corresponding test function.

**Integration** The integral of a 2D mesh function fi;j is defined as

$$F\_{i,j} = \int\limits\_{y\_0}^{y\_j} \int f(x,y)dxdy.$$

where f .x; y/ is a function that takes on the values of the discrete mesh function fi;j at the mesh points, but can also be evaluated in between the mesh points. The particular variation between mesh points can be taken as bilinear, but this is not important as we will use a product Trapezoidal rule to approximate the integral over a cell in the mesh and then we only need to evaluate f .x; y/ at the mesh points.

Suppose Fi;j is computed. The calculation of FiC1;j is then

$$\begin{aligned} F\_{i+1,j} &= F\_{i,j} + \int\_{x\_i}^{x\_{i+1}} \int\_0^y f(\mathbf{x}, \mathbf{y}) d\mathbf{y} d\mathbf{x} \\ &\approx \Delta x \frac{1}{2} \left( \int\_{\mathbb{y\_0}}^{\mathbf{y}\_j} f(\mathbf{x}\_i, \mathbf{y}) d\mathbf{y} + \int\_{\mathbf{y}\_0}^{\mathbf{y}\_j} f(\mathbf{x}\_{i+1}, \mathbf{y}) d\mathbf{y} \right). \end{aligned}$$

The integrals in the y direction can be approximated by a Trapezoidal rule. A similar idea can be used to compute Fi;j C<sup>1</sup>. Thereafter, FiC1;j C<sup>1</sup> can be computed by adding the integral over the final corner cell to FiC1;j <sup>C</sup> Fi;j C<sup>1</sup> Fi;j . Carry out the details of these computations and implement a function that can return Fi;j for all mesh indices i and j . Use the fact that the Trapezoidal rule is exact for linear functions and write a test function.

Filename: mesh\_calculus\_2D.

#### **Exercise 2.18: Implement Neumann conditions in 2D**

Modify the wave2D\_u0.py program, which solves the 2D wave equation utt <sup>D</sup> <sup>c</sup>2.uxx <sup>C</sup> uyy/ with constant wave velocity <sup>c</sup> and <sup>u</sup> <sup>D</sup> <sup>0</sup> on the boundary, to have Neumann boundary conditions: @u=@n D <sup>0</sup>. Include both scalar code (for debugging and reference) and vectorized code (for speed).

To test the code, use <sup>u</sup> D 1:2 as solution (I.x; y/ D 1:2, <sup>V</sup> D <sup>f</sup> D <sup>0</sup>, and c arbitrary), which should be exactly reproduced with any mesh as long as the stability criterion is satisfied. Another test is to use the plug-shaped pulse in the pulse function from Sect. 2.8 and the wave1D\_dn\_vc.py program. This pulse is exactly propagated in 1D if ct=x D <sup>1</sup>. Check that also the 2D program can propagate this pulse exactly in <sup>x</sup> direction (ct=x D <sup>1</sup>, y arbitrary) and <sup>y</sup> direction (ct=y D <sup>1</sup>, x arbitrary). Filename: wave2D\_dn.

#### **Exercise 2.19: Test the efficiency of compiled loops in 3D**

Extend the wave2D\_u0.py code and the Cython, Fortran, and C versions to 3D. Set up an efficiency experiment to determine the relative efficiency of pure scalar Python code, vectorized code, Cython-compiled loops, Fortran-compiled loops, and C-compiled loops. Normalize the CPU time for each mesh by the fastest version. Filename: wave3D\_u0.

#### **2.14 Applications of Wave Equations**

This section presents a range of wave equation models for different physical phenomena. Although many wave motion problems in physics can be modeled by the standard linear wave equation, or a similar formulation with a system of first-order equations, there are some exceptions. Perhaps the most important is water waves: these are modeled by the Laplace equation with time-dependent boundary conditions at the water surface (long water waves, however, can be approximated by a standard wave equation, see Sect. 2.14.7). Quantum mechanical waves constitute another example where the waves are governed by the Schrödinger equation, i.e., not by a standard wave equation. Many wave phenomena also need to take nonlinear effects into account when the wave amplitude is significant. Shock waves in the air is a primary example.

The derivations in the following are very brief. Those with a firm background in continuum mechanics will probably have enough knowledge to fill in the details, while other readers will hopefully get some impression of the physics and approximations involved when establishing wave equation models.

#### **2.14.1 Waves on a String**

Figure 2.10 shows a model we may use to derive the equation for waves on a string. The string is modeled as a set of discrete point masses (at mesh points) with elastic strings in between. The string has a large constant tension T . We let the mass at mesh point xi be mi . The displacement of this mass point in the y direction is denoted by ui.t/.

The motion of mass mi is governed by Newton's second law of motion. The position of the mass at time <sup>t</sup> is xii C ui.t/j , where i and j are unit vectors in the x and y direction, respectively. The acceleration is then u<sup>00</sup> <sup>i</sup> .t/<sup>j</sup> . Two forces are

**Fig. 2.10** Discrete string model with point masses connected by elastic strings

acting on the mass as indicated in Fig. 2.10. The force T acting toward the point xi<sup>1</sup> can be decomposed as

$$T^- = -T\sin\phi \mathbf{i} - T\cos\phi \mathbf{j} \,,$$

where is the angle between the force and the line <sup>x</sup> <sup>D</sup> xi . Let ui <sup>D</sup> ui ui<sup>1</sup> and let si <sup>D</sup> q u<sup>2</sup> <sup>i</sup> <sup>C</sup> .xi xi1/2 be the distance from mass mi<sup>1</sup> to mass mi . It is seen that cos <sup>D</sup> ui=si and sin <sup>D</sup> .xi xi1/=s or x=si if we introduce a constant mesh spacing x <sup>D</sup> xi xi<sup>1</sup>. The force can then be written

$$T^{-} = -T\frac{\Delta x}{\Delta s\_{i}}\dot{\mathbf{r}} - T\frac{\Delta u\_{i}}{\Delta s\_{i}}\dot{\mathbf{r}}\ .$$

The force <sup>T</sup> <sup>C</sup> acting toward xiC<sup>1</sup> can be calculated in a similar way:

$$\mathbf{T}^+ = T \frac{\Delta x}{\Delta s\_{i+1}} \mathbf{i} + T \frac{\Delta u\_{i+1}}{\Delta s\_{i+1}} \mathbf{j} \ .$$

Newton's second law becomes

$$m\_i u\_i''(t) \dot{J} = T^+ + T^-,$$

which gives the component equations

$$T\frac{\Delta x}{\Delta s\_i} = T\frac{\Delta x}{\Delta s\_{i+1}},\tag{2.120}$$

$$m\_i u\_i''(t) = T \frac{\Delta u\_{i+1}}{\Delta s\_{i+1}} - T \frac{\Delta u\_i}{\Delta s\_i} \,. \tag{2.121}$$

A basic reasonable assumption for a string is small displacements ui and small displacement gradients ui=x. For small <sup>g</sup> D ui=x we have that

$$
\Delta s\_i = \sqrt{\Delta u\_i^2 + \Delta x^2} = \Delta x \sqrt{1 + g^2} + \Delta x \left(1 + \frac{1}{2}g^2 + \mathcal{O}(g^4)\right) \approx \Delta x \dots
$$

Equation (2.120) is then simply the identity <sup>T</sup> D <sup>T</sup> , while (2.121) can be written as

$$m\_i u\_{i}^{\prime\prime}(t) = T \frac{\Delta u\_{i+1}}{\Delta x} - T \frac{\Delta u\_i}{\Delta x},$$

which upon division by x and introducing the density %i <sup>D</sup> mi=x becomes

$$
\varrho\_i u\_i''(t) = T \frac{1}{\Delta x^2} \left( u\_{i+1} - 2u\_i + u\_{i-1} \right) \,. \tag{2.122}
$$

We can now choose to approximate u<sup>00</sup> <sup>i</sup> by a finite difference in time and get the discretized wave equation,

$$\varrho\_i \frac{1}{\Delta t^2} \left( u\_i^{n+1} - 2u\_i^n - u\_i^{n-1} \right) = T \frac{1}{\Delta x^2} \left( u\_{i+1} - 2u\_i + u\_{i-1} \right) \,. \tag{2.123}$$

On the other hand, we may go to the continuum limit x ! <sup>0</sup> and replace ui.t/ by u.x; t/, %i by %.x/, and recognize that the right-hand side of (2.122) approaches @2u=@x2 as x ! <sup>0</sup>. We end up with the continuous model for waves on a string:

$$
\varrho \frac{\partial^2 u}{\partial t^2} = T \frac{\partial^2 u}{\partial x^2} \,. \tag{2.124}
$$

Note that the density % may change along the string, while the tension T is a constant. With variable wave velocity c.x/ <sup>D</sup> <sup>p</sup>T =%.x/ we can write the wave equation in the more standard form

$$\frac{\partial^2 u}{\partial t^2} = c^2(\mathbf{x}) \frac{\partial^2 u}{\partial \mathbf{x}^2}. \tag{2.125}$$

Because of the way % enters the equations, the variable wave velocity does *not* appear inside the derivatives as in many other versions of the wave equation. However, most strings of interest have constant %.

The end points of a string are fixed so that the displacement u is zero. The boundary conditions are therefore <sup>u</sup> D <sup>0</sup>.

**Damping** Air resistance and non-elastic effects in the string will contribute to reduce the amplitudes of the waves so that the motion dies out after some time. This damping effect can be modeled by a term but on the left-hand side of the equation

$$
\varrho \frac{\partial^2 u}{\partial t^2} + b \frac{\partial u}{\partial t} = T \frac{\partial^2 u}{\partial x^2}. \tag{2.126}
$$

The parameter <sup>b</sup> <sup>0</sup> is small for most wave phenomena, but the damping effect may become significant in long time simulations.

**External forcing** It is easy to include an external force acting on the string. Say we have a vertical force <sup>f</sup><sup>Q</sup> <sup>i</sup><sup>j</sup> acting on mass mi , modeling the effect of gravity on a string. This force affects the vertical component of Newton's law and gives rise to an extra term f .x; t/ <sup>Q</sup> on the right-hand side of (2.124). In the model (2.125) we would add a term f .x; t/ D f .x; t/=%.x/ <sup>Q</sup> .

**Modeling the tension via springs** We assumed, in the derivation above, that the tension in the string, T , was constant. It is easy to check this assumption by modeling the string segments between the masses as standard springs, where the force (tension T ) is proportional to the elongation of the spring segment. Let k be the spring constant, and set Ti <sup>D</sup> k` for the tension in the spring segment between xi<sup>1</sup> and xi , where ` is the elongation of this segment from the tensionfree state. A basic feature of a string is that it has high tension in the equilibrium position <sup>u</sup> <sup>D</sup> <sup>0</sup>. Let the string segment have an elongation `0 in the equilibrium position. After deformation of the string, the elongation is ` <sup>D</sup> `0 <sup>C</sup> si : Ti <sup>D</sup> k.`0 <sup>C</sup> si/ k.`0 <sup>C</sup> x/. This shows that Ti is independent of <sup>i</sup>. Moreover, the extra approximate elongation x is very small compared to `0, so we may well set Ti <sup>D</sup> <sup>T</sup> <sup>D</sup> k`0. This means that the tension is completely dominated by the initial tension determined by the tuning of the string. The additional deformations of the spring during the vibrations do not introduce significant changes in the tension.

#### **2.14.2 Elastic Waves in a Rod**

Consider an elastic rod subject to a hammer impact at the end. This experiment will give rise to an elastic deformation pulse that travels through the rod. A mathematical model for longitudinal waves along an elastic rod starts with the general equation for deformations and stresses in an elastic medium,

$$
\varrho \mathfrak{u}\_{ll} = \nabla \cdot \mathfrak{a} + \varrho f \,, \tag{2.127}
$$

where % is the density, u the displacement field, the stress tensor, and f body forces. The latter has normally no impact on elastic waves.

For stationary deformation of an elastic rod, aligned with the x axis, one has that xx <sup>D</sup> Eux, with all other stress components being zero. The parameter <sup>E</sup> is known as Young's modulus. Moreover, we set u D u.x; t/i and neglect the radial contraction and expansion (where Poisson's ratio is the important parameter). Assuming that this simple stress and deformation field is a good approximation, (2.127) simplifies to

$$
\varrho \frac{\partial^2 u}{\partial t^2} = \frac{\partial}{\partial x} \left( E \frac{\partial u}{\partial x} \right) . \tag{2.128}
$$

The associated boundary conditions are <sup>u</sup> or xx <sup>D</sup> Eux known, typically <sup>u</sup> <sup>D</sup> <sup>0</sup> for a fixed end and xx <sup>D</sup> <sup>0</sup> for a free end.

#### **2.14.3 Waves on a Membrane**

Think of a thin, elastic membrane with shape as a circle or rectangle. This membrane can be brought into oscillatory motion and will develop elastic waves. We can model this phenomenon somewhat similar to waves in a rod: waves in a membrane are simply the two-dimensional counterpart. We assume that the material is deformed in the z direction only and write the elastic displacement field on the form u.x; y; t/ D w.x; y; t/i. The <sup>z</sup> coordinate is omitted since the membrane is thin and all properties are taken as constant throughout the thickness. Inserting this displacement field in Newton's 2nd law of motion (2.127) results in

$$
\varrho \frac{\partial^2 w}{\partial t^2} = \frac{\partial}{\partial x} \left( \mu \frac{\partial w}{\partial x} \right) + \frac{\partial}{\partial y} \left( \mu \frac{\partial w}{\partial y} \right) \,. \tag{2.129}
$$

This is nothing but a wave equation in w.x; y; t/, which needs the usual initial conditions on <sup>w</sup> and wt as well as a boundary condition <sup>w</sup> <sup>D</sup> <sup>0</sup>. When computing the stress in the membrane, one needs to split into a constant high-stress component due to the fact that all membranes are normally pre-stressed, plus a component proportional to the displacement and governed by the wave motion.

#### **2.14.4 The Acoustic Model for Seismic Waves**

Seismic waves are used to infer properties of subsurface geological structures. The physical model is a heterogeneous elastic medium where sound is propagated by small elastic vibrations. The general mathematical model for deformations in an elastic medium is based on Newton's second law,

$$
\varrho \mathfrak{u}\_{ll} = \nabla \cdot \mathfrak{a} + \varrho f \,, \tag{2.130}
$$

and a constitutive law relating to u, often Hooke's generalized law,

$$\boldsymbol{\sigma} = K \nabla \cdot \boldsymbol{\mathfrak{u}} \, I + G \left( \nabla \boldsymbol{\mathfrak{u}} + (\nabla \boldsymbol{\mathfrak{u}})^T - \frac{2}{3} \nabla \cdot \boldsymbol{\mathfrak{u}} \, I \right) \,. \tag{2.131}$$

Here, u is the displacement field, is the stress tensor, I is the identity tensor, % is the medium's density, f are body forces (such as gravity), <sup>K</sup> is the medium's bulk modulus and G is the shear modulus. All these quantities may vary in space, while u and will also show significant variation in time during wave motion.

The acoustic approximation to elastic waves arises from a basic assumption that the second term in Hooke's law, representing the deformations that give rise to shear stresses, can be neglected. This assumption can be interpreted as approximating the geological medium by a fluid. Neglecting also the body forces f , (2.130) becomes

$$
\varrho \mathfrak{u}\_{ll} = \nabla (K \nabla \cdot \mathfrak{u}) \,. \tag{2.132}
$$

Introducing p as a pressure via

$$p = -K\nabla \cdot \mathbf{u},\tag{2.133}$$

and dividing (2.132) by %, we get

$$
\mathfrak{u}\_{ll} = -\frac{1}{\varrho} \nabla p \,. \tag{2.134}
$$

Taking the divergence of this equation, using r u D p=K from (2.133), gives the *acoustic approximation to elastic waves*:

$$p\_{ll} = K \nabla \cdot \left(\frac{1}{\varrho} \nabla p\right). \tag{2.135}$$

This is a standard, linear wave equation with variable coefficients. It is common to add a source term s.x; y; z; t/ to model the generation of sound waves:

$$p\_{ll} = K\nabla \cdot \left(\frac{1}{\varrho} \nabla p\right) + s\ . \tag{2.136}$$

A common additional approximation of (2.136) is based on using the chain rule on the right-hand side,

$$K\nabla \cdot \left(\frac{1}{\varrho} \nabla p\right) = \frac{K}{\varrho} \nabla^2 p + K \nabla \left(\frac{1}{\varrho}\right) \cdot \nabla p \approx \frac{K}{\varrho} \nabla^2 p,$$

under the assumption that the relative spatial gradient r%<sup>1</sup> D %<sup>2</sup>r% is small. This approximation results in the simplified equation

$$p\_{ll} = \frac{K}{\varrho} \nabla^2 p + \text{s.}\tag{2.137}$$

The acoustic approximations to seismic waves are used for sound waves in the ground, and the Earth's surface is then a boundary where p equals the atmospheric pressure p0 such that the boundary condition becomes <sup>p</sup> <sup>D</sup> p0.

p **Anisotropy** Quite often in geological materials, the effective wave velocity <sup>c</sup> D K=% is different in different spatial directions because geological layers are compacted, and often twisted, in such a way that the properties in the horizontal and vertical direction differ. With z as the vertical coordinate, we can introduce a vertical wave velocity cz and a horizontal wave velocity ch, and generalize (2.137) to

$$p\_{ll} = c\_z^2 p\_{zz} + c\_h^2 (p\_{xx} + p\_{yy}) + s \,. \tag{2.138}$$

#### **2.14.5 Sound Waves in Liquids and Gases**

Sound waves arise from pressure and density variations in fluids. The starting point of modeling sound waves is the basic equations for a compressible fluid where we omit viscous (frictional) forces, body forces (gravity, for instance), and temperature effects:

$$
\varrho\_l + \nabla \cdot (\varrho \mathfrak{u}) = 0,\tag{2.139}
$$

$$
\varrho \mathfrak{u}\_l + \varrho \mathfrak{u} \cdot \nabla \mathfrak{u} = -\nabla p,\tag{2.140}
$$

$$
\varrho = \varrho(p) \,. \tag{2.141}
$$

These equations are often referred to as the Euler equations for the motion of a fluid. The parameters involved are the density %, the velocity u, and the pressure <sup>p</sup>. Equation (2.139) reflects mass balance, (2.140) is Newton's second law for a fluid, with frictional and body forces omitted, and (2.141) is a constitutive law relating density to pressure by thermodynamic considerations. A typical model for (2.141) is the so-called isentropic relation15, valid for adiabatic processes where there is no heat transfer:

$$
\varrho = \varrho\_0 \left(\frac{p}{p\_0}\right)^{1/\gamma}.\tag{2.142}
$$

Here, p0 and %0 are reference values for p and % when the fluid is at rest, and is the ratio of specific heat at constant pressure and constant volume ( D 5=3 for air).

The key approximation in a mathematical model for sound waves is to assume that these waves are small perturbations to the density, pressure, and velocity. We

<sup>15</sup> http://en.wikipedia.org/wiki/Isentropic\_process

therefore write

$$\begin{aligned} p &= p\_0 + \hat{p}, \\ \varrho &= \varrho\_0 + \hat{\varrho}, \\ \mathfrak{u} &= \hat{\mathfrak{u}}, \end{aligned}$$

where we have decomposed the fields in a constant equilibrium value, corresponding to u D <sup>0</sup>, and a small perturbation marked with a hat symbol. By inserting these decompositions in (2.139) and (2.140), neglecting all product terms of small perturbations and/or their derivatives, and dropping the hat symbols, one gets the following linearized PDE system for the small perturbations in density, pressure, and velocity:

$$
\varrho\_l + \varrho\_0 \nabla \cdot \mathfrak{u} = 0,\tag{2.143}
$$

$$
\varrho\_0 \mathfrak{u}\_l = -\nabla p \,. \tag{2.144}
$$

Now we can eliminate %t by differentiating the relation %.p/,

$$\varrho\_l = \varrho\_0 \frac{1}{\mathcal{Y}} \left(\frac{p}{p\_0}\right)^{1/\gamma - 1} \frac{1}{p\_0} p\_l = \frac{\varrho\_0}{\mathcal{Y} p\_0} \left(\frac{p}{p\_0}\right)^{1/\gamma - 1} p\_l \dots$$

The product term p1= 1pt can be linearized as p1= 1 <sup>0</sup> pt , resulting in

$$
\varrho\_l \approx \frac{\varrho\_0}{\mathcal{Y}p\_0} p\_l \dots
$$

We then get

$$p\_l + \gamma p\_0 \nabla \cdot \mathfrak{u} = 0,\tag{2.145}$$

$$
\mathfrak{u}\_l = -\frac{1}{\varrho\_0} \nabla p \,. \tag{2.146}
$$

Taking the divergence of (2.146) and differentiating (2.145) with respect to time gives the possibility to easily eliminate r <sup>u</sup><sup>t</sup> and arrive at a standard, linear wave equation for p:

$$p\_{ll} = c^2 \nabla^2 p,\tag{2.147}$$

where <sup>c</sup> D <sup>p</sup> p0=%0 is the speed of sound in the fluid.

#### **2.14.6 Spherical Waves**

Spherically symmetric three-dimensional waves propagate in the radial direction r only so that <sup>u</sup> D u.r; t/. The fully three-dimensional wave equation

$$\frac{\partial^2 u}{\partial t^2} = \nabla \cdot (c^2 \nabla u) + f$$

then reduces to the spherically symmetric wave equation

$$\frac{\partial^2 u}{\partial t^2} = \frac{1}{r^2} \frac{\partial}{\partial r} \left( c^2(r) r^2 \frac{\partial u}{\partial r} \right) + f(r, t), \quad r \in (0, R), \ t > 0. \tag{2.148}$$

One can easily show that the function v.r; t/ D ru.r; t/ fulfills a standard wave equation in Cartesian coordinates if <sup>c</sup> is constant. To this end, insert <sup>u</sup> D v=r in

$$\frac{1}{r^2} \frac{\partial}{\partial r} \left( c^2(r) r^2 \frac{\partial u}{\partial r} \right)$$

to obtain

$$r\left(\frac{dc^2}{dr}\frac{\partial v}{\partial r} + c^2\frac{\partial^2 v}{\partial r^2}\right) - \frac{dc^2}{dr}v\,.$$

The two terms in the parenthesis can be combined to

$$r\frac{\partial}{\partial r}\left(c^2\frac{\partial v}{\partial r}\right).$$

which is recognized as the variable-coefficient Laplace operator in one Cartesian coordinate. The spherically symmetric wave equation in terms of v.r; t/ now becomes

$$\frac{\partial^2 v}{\partial t^2} = \frac{\partial}{\partial r} \left( c^2(r) \frac{\partial v}{\partial r} \right) - \frac{1}{r} \frac{dc^2}{dr} v + rf(r, t), \quad r \in (0, R), \ t > 0. \tag{2.149}$$

In the case of constant wave velocity c, this equation reduces to the wave equation in a single Cartesian coordinate called r:

$$\frac{\partial^2 v}{\partial t^2} = c^2 \frac{\partial^2 v}{\partial r^2} + rf(r, t), \quad r \in (0, R), \ t > 0 \,. \tag{2.150}$$

That is, any program for solving the one-dimensional wave equation in a Cartesian coordinate system can be used to solve (2.150), provided the source term is multiplied by the coordinate, and that we divide the Cartesian mesh solution by r to get the spherically symmetric solution. Moreover, if <sup>r</sup> D <sup>0</sup> is included in the domain, spherical symmetry demands that @u=@r D <sup>0</sup> at <sup>r</sup> D <sup>0</sup>, which means that

$$\frac{\partial u}{\partial r} = \frac{1}{r^2} \left( r \frac{\partial v}{\partial r} - v \right) = 0, \quad r = 0 \dots$$

For this to hold in the limit <sup>r</sup> ! <sup>0</sup>, we must have v.0; t/ D <sup>0</sup> at least as a necessary condition. In most practical applications, we exclude <sup>r</sup> D <sup>0</sup> from the domain and assume that some boundary condition is assigned at <sup>r</sup> D , for some >0.

#### **2.14.7 The Linear Shallow Water Equations**

The next example considers water waves whose wavelengths are much larger than the depth and whose wave amplitudes are small. This class of waves may be generated by catastrophic geophysical events, such as earthquakes at the sea bottom, landslides moving into water, or underwater slides (or a combination, as earthquakes frequently release avalanches of masses). For example, a subsea earthquake will normally have an extension of many kilometers but lift the water only a few meters. The wave length will have a size dictated by the earthquake area, which is much lager than the water depth, and compared to this wave length, an amplitude of a few meters is very small. The water is essentially a thin film, and mathematically we can average the problem in the vertical direction and approximate the 3D wave phenomenon by 2D PDEs. Instead of a moving water domain in three space dimensions, we get a horizontal 2D domain with an unknown function for the surface elevation and the water depth as a variable coefficient in the PDEs.

Let .x; y; t/ be the elevation of the water surface, H.x; y/ the water depth corresponding to a flat surface ( D <sup>0</sup>), u.x; y; t/ and v.x; y; t/ the depth-averaged horizontal velocities of the water. Mass and momentum balance of the water volume give rise to the PDEs involving these quantities:

$$\eta\_l = -(Hu)\_{\underline{x}} - (Hv)\_{\underline{x}} \tag{2.151}$$

$$\mu\_l = -\mathbf{g}\,\eta\_{\times},\tag{2.152}$$

$$\mathbf{v}\_l = -\mathbf{g}\,\eta\_\mathbf{y},\tag{2.153}$$

where g is the acceleration of gravity. Equation (2.151) corresponds to mass balance while the other two are derived from momentum balance (Newton's second law).

The initial conditions associated with (2.151)–(2.153) are , u, and v prescribed at <sup>t</sup> D <sup>0</sup>. A common condition is to have some water elevation D I.x; y/ and assume that the surface is at rest: <sup>u</sup> D <sup>v</sup> D <sup>0</sup>. A subsea earthquake usually means a sufficiently rapid motion of the bottom and the water volume to say that the bottom deformation is mirrored at the water surface as an initial lift I.x; y/ and that <sup>u</sup> D <sup>v</sup> D <sup>0</sup>.

Boundary conditions may be prescribed for incoming, known waves, or zero normal velocity at reflecting boundaries (steep mountains, for instance): unx <sup>C</sup> vny <sup>D</sup> <sup>0</sup>, where .nx; ny/ is the outward unit normal to the boundary. More sophisticated boundary conditions are needed when waves run up at the shore, and at open boundaries where we want the waves to leave the computational domain undisturbed.

Equations (2.151), (2.152), and (2.153) can be transformed to a standard, linear wave equation. First, multiply (2.152) and (2.153) by H, differentiate (2.152) with respect to x and (2.153) with respect to y. Second, differentiate (2.151) with respect to <sup>t</sup> and use that .H u/xt <sup>D</sup> .H ut/x and .H v/yt <sup>D</sup> .H vt/y when <sup>H</sup> is independent of t. Third, eliminate .H ut/x and .H vt/y with the aid of the other two differentiated equations. These manipulations result in a standard, linear wave equation for :

$$\eta\_{ll} = (\mathbf{g}H\eta\_{\mathbf{x}})\_{\mathbf{x}} + (\mathbf{g}H\eta\_{\mathbf{y}})\_{\mathbf{y}} = \nabla \cdot (\mathbf{g}H\nabla \eta) \,. \tag{2.154}$$

In the case we have an initial non-flat water surface at rest, the initial conditions become <sup>D</sup> I.x; y/ and t <sup>D</sup> <sup>0</sup>. The latter follows from (2.151) if <sup>u</sup> <sup>D</sup> <sup>v</sup> <sup>D</sup> <sup>0</sup>, or simply from the fact that the vertical velocity of the surface is t , which is zero for a surface at rest.

The system (2.151)–(2.153) can be extended to handle a time-varying bottom topography, which is relevant for modeling long waves generated by underwater slides. In such cases the water depth function H is also a function of t, due to the moving slide, and one must add a time-derivative term Ht to the left-hand side of (2.151). A moving bottom is best described by introducing <sup>z</sup> <sup>D</sup> H0 as the stillwater level, <sup>z</sup> D B.x; y; t/ as the time- and space-varying bottom topography, so that <sup>H</sup> <sup>D</sup> H0 B.x; y; t/. In the elimination of <sup>u</sup> and <sup>v</sup> one may assume that the dependence of H on t can be neglected in the terms .H u/xt and .H v/yt . We then end up with a source term in (2.154), because of the moving (accelerating) bottom:

$$
\eta\_{ll} = \nabla \cdot (\mathbf{g}H \nabla \eta) + B\_{ll} \,. \tag{2.155}
$$

The reduction of (2.155) to 1D, for long waves in a straight channel, or for approximately plane waves in the ocean, is trivial by assuming no change in y direction (@=@y D <sup>0</sup>):

$$
\eta\_{ll} = (\mathbf{g}H\eta\_{\mathbf{x}})\_{\mathbf{x}} + B\_{ll} \,. \tag{2.156}
$$

**Wind drag on the surface** Surface waves are influenced by the drag of the wind, and if the wind velocity some meters above the surface is .U; V /, the wind drag gives contributions CV <sup>p</sup>U2 <sup>C</sup> <sup>V</sup> 2U and CV <sup>p</sup>U2 C <sup>V</sup> 2V to (2.152) and (2.153), respectively, on the right-hand sides.

**Bottom drag** The waves will experience a drag from the bottom, often roughly modeled by a term similar to the wind drag: CB <sup>p</sup>u2 C v2u on the right-hand side of (2.152) and CB <sup>p</sup>u2 C v2v on the right-hand side of (2.153). Note that in this case the PDEs (2.152) and (2.153) become nonlinear and the elimination of u and v to arrive at a 2nd-order wave equation for is not possible anymore.

**Effect of the Earth's rotation** Long geophysical waves will often be affected by the rotation of the Earth because of the Coriolis force. This force gives rise to a term f v on the right-hand side of (2.152) and f u on the right-hand side of (2.153). Also in this case one cannot eliminate u and v to work with a single equation for . The Coriolis parameter is <sup>f</sup> D 2˝ sin , where ˝ is the angular velocity of the earth and is the latitude.

#### **2.14.8 Waves in Blood Vessels**

The flow of blood in our bodies is basically fluid flow in a network of pipes. Unlike rigid pipes, the walls in the blood vessels are elastic and will increase their diameter when the pressure rises. The elastic forces will then push the wall back and accelerate the fluid. This interaction between the flow of blood and the deformation of the vessel wall results in waves traveling along our blood vessels.

A model for one-dimensional waves along blood vessels can be derived from averaging the fluid flow over the cross section of the blood vessels. Let x be a coordinate along the blood vessel and assume that all cross sections are circular, though with different radii R.x; t/. The main quantities to compute is the cross section area A.x; t/, the averaged pressure P .x; t/, and the total volume flux Q.x; t/. The area of this cross section is

$$A(\mathbf{x}, t) = 2\pi \int\_0^{\vec{R}(\mathbf{x}, t)} r dr \,. \tag{2.157}$$

Let vx.x; t/ be the velocity of blood averaged over the cross section at point x. The volume flux, being the total volume of blood passing a cross section per time unit, becomes

$$
\underline{Q}(\mathbf{x},t) = A(\mathbf{x},t)v\_{\mathbf{x}}(\mathbf{x},t) \,. \tag{2.158}
$$

Mass balance and Newton's second law lead to the PDEs

$$\frac{\partial A}{\partial t} + \frac{\partial \underline{Q}}{\partial x} = 0,\tag{2.159}$$

$$\frac{\partial \mathcal{Q}}{\partial t} + \frac{\chi + 2}{\chi + 1} \frac{\partial}{\partial x} \left( \frac{\mathcal{Q}^2}{A} \right) + \frac{A}{\varrho} \frac{\partial P}{\partial x} = -2\pi (\wp + 2) \frac{\mu}{\varrho} \frac{\mathcal{Q}}{A},\tag{2.160}$$

where is a parameter related to the velocity profile, % is the density of blood, and is the dynamic viscosity of blood.

We have three unknowns A, Q, and P, and two equations (2.159) and (2.160). A third equation is needed to relate the flow to the deformations of the wall. A common form for this equation is

$$\frac{\partial P}{\partial t} + \frac{1}{C} \frac{\partial Q}{\partial x} = 0,\tag{2.161}$$

where C is the compliance of the wall, given by the constitutive relation

$$C = \frac{\partial A}{\partial P} + \frac{\partial A}{\partial t},\tag{2.162}$$

which requires a relationship between A and P. One common model is to view the vessel wall, locally, as a thin elastic tube subject to an internal pressure. This gives the relation

$$P = P\_0 + \frac{\pi hE}{(1 - \nu^2)A\_0} \left(\sqrt{A} - \sqrt{A\_0}\right),$$

where P0 and A0 are corresponding reference values when the wall is not deformed, h is the thickness of the wall, and E and are Young's modulus and Poisson's ratio of the elastic material in the wall. The derivative becomes

$$C = \frac{\partial A}{\partial P} = \frac{2(1 - \nu^2)A\_0}{\pi hE} \sqrt{A\_0} + 2\left(\frac{(1 - \nu^2)A\_0}{\pi hE}\right)^2 (P - P\_0) \,. \tag{2.163}$$

Another (nonlinear) deformation model of the wall, which has a better fit with experiments, is

$$P = P\_0 \exp\left(\beta (A/A\_0 - 1)\right),$$

where ˇ is some parameter to be estimated. This law leads to

$$C = \frac{\partial A}{\partial P} = \frac{A\_0}{\beta P}.\tag{2.164}$$

**Reduction to the standard wave equation** It is not uncommon to neglect the viscous term on the right-hand side of (2.160) and also the quadratic term with Q<sup>2</sup> on the left-hand side. The reduced equations (2.160) and (2.161) form a first-order linear wave equation system:

$$C\frac{\partial P}{\partial t} = -\frac{\partial \mathcal{Q}}{\partial x},\tag{2.165}$$

$$\frac{\partial \underline{Q}}{\partial t} = -\frac{A}{\underline{\varrho}} \frac{\partial P}{\partial x} \,. \tag{2.166}$$

These can be combined into standard 1D wave PDE by differentiating the first equation with respect to t and the second with respect to x,

$$
\frac{\partial}{\partial t} \left( C \frac{\partial P}{\partial t} \right) = \frac{\partial}{\partial x} \left( \frac{A}{\varrho} \frac{\partial P}{\partial x} \right),
$$

which can be approximated by

$$\frac{\partial^2 \mathcal{Q}}{\partial t^2} = c^2 \frac{\partial^2 \mathcal{Q}}{\partial x^2}, \quad c = \sqrt{\frac{A}{\varrho \mathcal{C}}},\tag{2.167}$$

where the A and C in the expression for c are taken as constant reference values.

#### **2.14.9 Electromagnetic Waves**

Light and radio waves are governed by standard wave equations arising from Maxwell's general equations. When there are no charges and no currents, as in a vacuum, Maxwell's equations take the form

$$\begin{aligned} \nabla \cdot \mathbf{E} &= 0, \\ \nabla \cdot \mathbf{B} &= 0, \\ \nabla \times \mathbf{E} &= -\frac{\partial \mathbf{B}}{\partial t}, \\ \nabla \times \mathbf{B} &= \mu\_0 \epsilon\_0 \frac{\partial \mathbf{E}}{\partial t}, \end{aligned}$$

where <sup>0</sup> <sup>D</sup> 8:854187817620 <sup>10</sup><sup>12</sup> (F/m) is the permittivity of free space, also known as the electric constant, and <sup>0</sup> <sup>D</sup> 1:2566370614 <sup>10</sup><sup>6</sup> (H/m) is the permeability of free space, also known as the magnetic constant. Taking the curl of the two last equations and using the mathematical identity

$$
\nabla \times (\nabla \times \mathbf{E}) = \nabla(\nabla \cdot \mathbf{E}) - \nabla^2 \mathbf{E} = -\nabla^2 \mathbf{E} \text{ when } \nabla \cdot \mathbf{E} = 0,
$$

gives the wave equation governing the electric and magnetic field:

$$\frac{\partial^2 \mathbf{E}}{\partial t^2} = c^2 \nabla^2 \mathbf{E},\tag{2.168}$$

$$\frac{\partial^2 \mathbf{B}}{\partial t^2} = c^2 \nabla^2 \mathbf{B},\tag{2.169}$$

with <sup>c</sup> <sup>D</sup> 1=p0
0 as the velocity of light. Each component of <sup>E</sup> and <sup>B</sup> fulfills a wave equation and can hence be solved independently.

#### **2.15 Exercises**

#### **Exercise 2.20: Simulate waves on a non-homogeneous string**

Simulate waves on a string that consists of two materials with different density. The tension in the string is constant, but the density has a jump at the middle of the string. Experiment with different sizes of the jump and produce animations that visualize the effect of the jump on the wave motion.

*Hint* According to Sect. 2.14.1, the density enters the mathematical model as % in %utt <sup>D</sup> T uxx, where <sup>T</sup> is the string tension. Modify, e.g., the wave1D\_u0v.py code to incorporate the tension and two density values. Make a mesh function rho with density values at each spatial mesh point. A value for the tension may be 150 N. Corresponding density values can be computed from the wave velocity estimations in the guitar function in the wave1D\_u0v.py file. Filename: wave1D\_u0\_sv\_discont.

#### **Exercise 2.21: Simulate damped waves on a string**

Formulate a mathematical model for damped waves on a string. Use data from Sect. 2.3.6, and tune the damping parameter so that the string is very close to the rest state after 15 s. Make a movie of the wave motion.

Filename: wave1D\_u0\_sv\_damping.

#### **Exercise 2.22: Simulate elastic waves in a rod**

A hammer hits the end of an elastic rod. The exercise is to simulate the resulting wave motion using the model (2.128) from Sect. 2.14.2. Let the rod have length L and let the boundary <sup>x</sup> <sup>D</sup> <sup>L</sup> be stress free so that xx <sup>D</sup> <sup>0</sup>, implying that @u=@x <sup>D</sup> <sup>0</sup>. The left end <sup>x</sup> D <sup>0</sup> is subject to a strong stress pulse (the hammer), modeled as

$$\sigma\_{xx}(t) = \begin{cases} \mathcal{S}, & 0 < t \le t\_s, \\ 0, & t > t\_s \end{cases}$$

:

The corresponding condition on <sup>u</sup> becomes ux <sup>D</sup> S=E for <sup>t</sup> ts and zero afterwards (recall that xx <sup>D</sup> Eux). This is a non-homogeneous Neumann condition, and you will need to approximate this condition and combine it with the scheme (the ideas and manipulations follow closely the handling of a non-zero initial condition ut <sup>D</sup> <sup>V</sup> in wave PDEs or the corresponding second-order ODEs for vibrations). Filename: wave\_rod.

#### **Exercise 2.23: Simulate spherical waves**

Implement a model for spherically symmetric waves using the method described in Sect. 2.14.6. The boundary condition at <sup>r</sup> D <sup>0</sup> must be @u=@r D <sup>0</sup>, while the condition at <sup>r</sup> D <sup>R</sup> can either be <sup>u</sup> D <sup>0</sup> or a radiation condition as described in Problem 2.12. The <sup>u</sup> D <sup>0</sup> condition is sufficient if <sup>R</sup> is so large that the amplitude

**Fig. 2.11** Sketch of initial water surface due to a subsea earthquake

of the spherical wave has become insignificant. Make movie(s) of the case where the source term is located around <sup>r</sup> D <sup>0</sup> and sends out pulses

$$f(r,t) = \begin{cases} \,^cQ \exp\left(-\frac{r^2}{2\Delta r^2}\right) \sin\omega t, & \sin\omega t \ge 0\\ 0, & \sin\omega t < 0 \end{cases} .$$

Here, Q and ! are constants to be chosen.

*Hint* Use the program wave1D\_u0v.py as a starting point. Let solver compute the <sup>v</sup> function and then set <sup>u</sup> D v=r. However, <sup>u</sup> D v=r for <sup>r</sup> D <sup>0</sup> requires special treatment. One possibility is to compute u[1:] = v[1:]/r[1:] and then set u[0]=u[1]. The latter makes it evident that @u=@r D <sup>0</sup> in a plot. Filename: wave1D\_spherical.

#### **Problem 2.24: Earthquake-generated tsunami over a subsea hill**

A subsea earthquake leads to an immediate lift of the water surface, see Fig. 2.11. The lifted water surface splits into two tsunamis, one traveling to the right and one to the left, as depicted in Fig. 2.12. Since tsunamis are normally very long waves, compared to the depth, with a small amplitude, compared to the wave length, a standard wave equation is relevant:

$$
\eta\_{ll} = (\mathbf{g}H(\mathbf{x})\eta\_{\mathbf{x}})\_{\mathbf{x}},
$$

where is the elevation of the water surface, g is the acceleration of gravity, and H.x/ is the still water depth.

To simulate the right-going tsunami, we can impose a symmetry boundary at <sup>x</sup> D <sup>0</sup>: @=@x D <sup>0</sup>. We then simulate the wave motion in Œ0; L. Unless the ocean ends at <sup>x</sup> D <sup>L</sup>, the waves should travel undisturbed through the boundary <sup>x</sup> D <sup>L</sup>. A radiation condition as explained in Problem 2.12 can be used for this purpose. Alternatively, one can just stop the simulations before the wave hits the boundary at <sup>x</sup> D <sup>L</sup>. In that case it does not matter what kind of boundary condition we use at <sup>x</sup> D <sup>L</sup>. Imposing D <sup>0</sup> and stopping the simulations when jn <sup>i</sup> <sup>j</sup> <sup>&</sup>gt;, <sup>i</sup> <sup>D</sup> Nx <sup>1</sup>, is a possibility ( is a small parameter).

**Fig. 2.12** An initial surface elevation is split into two waves

**Fig. 2.13** Sketch of an earthquake-generated tsunami passing over a subsea hill

The shape of the initial surface can be taken as a Gaussian function,

$$I(\mathbf{x}; I\_0, I\_a, I\_m, I\_s) = I\_0 + I\_a \exp\left(-\left(\frac{\mathbf{x} - I\_m}{I\_s}\right)^2\right),\tag{2.170}$$

with Im <sup>D</sup> <sup>0</sup> reflecting the location of the peak of I.x/ and Is being a measure of the width of the function I.x/ (Is is p2 times the standard deviation of the familiar normal distribution curve).

Now we extend the problem with a hill at the sea bottom, see Fig. 2.13. The wave speed <sup>c</sup> <sup>D</sup> <sup>p</sup>gH.x/ <sup>D</sup> <sup>p</sup>g.H0 B.x// will then be reduced in the shallow water above the hill.

One possible form of the hill is a Gaussian function,

$$B(\mathbf{x}; B\_0, B\_a, B\_m, B\_s) = B\_0 + B\_d \exp\left(-\left(\frac{\mathbf{x} - B\_m}{B\_s}\right)^2\right),\tag{2.171}$$

but many other shapes are also possible, e.g., a "cosine hat" where

$$B(\mathbf{x}; B\_0, B\_a, B\_m, B\_s) = B\_0 + B\_a \cos \left( \pi \frac{\mathbf{x} - B\_m}{2B\_s} \right),\tag{2.172}$$

when <sup>x</sup> <sup>2</sup> ŒBm Bs ; Bm <sup>C</sup> Bs while <sup>B</sup> <sup>D</sup> B0 outside this interval.

Also an abrupt construction may be tried:

$$B(\chi; B\_0, B\_a, B\_m, B\_s) = B\_0 + B\_a,\tag{2.173}$$

for <sup>x</sup> <sup>2</sup> ŒBm Bs; Bm <sup>C</sup> Bs while <sup>B</sup> <sup>D</sup> B0 outside this interval.

The wave1D\_dn\_vc.py program can be used as starting point for the implementation. Visualize both the bottom topography and the water surface elevation in the same plot. Allow for a flexible choice of bottom shape: (2.171), (2.172), (2.173), or B.x/ <sup>D</sup> B0 (flat).

The purpose of this problem is to explore the quality of the numerical solution n i for different shapes of the bottom obstruction. The "cosine hat" and the box-shaped hills have abrupt changes in the derivative of H.x/ and are more likely to generate numerical noise than the smooth Gaussian shape of the hill. Investigate if this is true.

Filename: tsunami1D\_hill.

#### **Problem 2.25: Earthquake-generated tsunami over a 3D hill**

This problem extends Problem 2.24 to a three-dimensional wave phenomenon, governed by the 2D PDE

$$\eta\_{ll} = (\mathbf{g}H\eta\_{\mathbf{x}})\_{\mathbf{x}} + (\mathbf{g}H\eta\_{\mathbf{y}})\_{\mathbf{y}} = \nabla \cdot (\mathbf{g}H\nabla \eta) \,. \tag{2.174}$$

We assume that the earthquake arises from a fault along the line <sup>x</sup> D <sup>0</sup> in the xyplane so that the initial lift of the surface can be taken as I.x/ in Problem 2.24. That is, a plane wave is propagating to the right, but will experience bending because of the bottom.

The bottom shape is now a function of x and y. An "elliptic" Gaussian function in two dimensions, with its peak at .Bmx; Bmy/, generalizes (2.171):

$$B = B\_0 + B\_a \exp\left(-\left(\frac{\chi - B\_{mx}}{B\_s}\right)^2 - \left(\frac{\chi - B\_{my}}{bB\_s}\right)^2\right),\tag{2.175}$$

where <sup>b</sup> is a scaling parameter: <sup>b</sup> D <sup>1</sup> gives a circular Gaussian function with circular contour lines, while <sup>b</sup> ¤ <sup>1</sup> gives an elliptic shape with elliptic contour lines. To indicate the input parameters in the model, we may write

$$B = B(\mathfrak{x}; B\_0, B\_a, B\_{m\mathfrak{x}}, B\_{my}, B\_s, b) \dots$$

The "cosine hat" (2.172) can also be generalized to

$$B = B\_0 + B\_a \cos\left(\pi \frac{\mathbf{x} - B\_{m\times}}{2B\_s}\right) \cos\left(\pi \frac{\mathbf{y} - B\_{my}}{2B\_s}\right),\tag{2.176}$$

when <sup>0</sup> <sup>p</sup>x2 <sup>C</sup> y2 Bs and <sup>B</sup> <sup>D</sup> B0 outside this circle.

A box-shaped obstacle means that

$$B(\mathbf{x}; B\_0, B\_a, B\_m, B\_s, b) = B\_0 + B\_a \tag{2.177}$$

for x and y inside a rectangle

$$B\_{m\chi} - B\_s \le \chi \le B\_{m\chi} + B\_s, \quad B\_{my} - bB\_s \le \chi \le B\_{my} + bB\_s,$$

and <sup>B</sup> <sup>D</sup> B0 outside this rectangle. The <sup>b</sup> parameter controls the rectangular shape of the cross section of the box.

Note that the initial condition and the listed bottom shapes are symmetric around the line <sup>y</sup> D Bmy. We therefore expect the surface elevation also to be symmetric with respect to this line. This means that we can halve the computational domain by working with Œ0; Lx - Œ0; Bmy. Along the upper boundary, <sup>y</sup> D Bmy, we must impose the symmetry condition @=@n D <sup>0</sup>. Such a symmetry condition (x <sup>D</sup> <sup>0</sup>) is also needed at the <sup>x</sup> <sup>D</sup> <sup>0</sup> boundary because the initial condition has a symmetry here. At the lower boundary <sup>y</sup> D <sup>0</sup> we also set a Neumann condition (which becomes y <sup>D</sup> <sup>0</sup>). The wave motion is to be simulated until the wave hits the reflecting boundaries where @=@n <sup>D</sup> x <sup>D</sup> <sup>0</sup> (one can also set <sup>D</sup> <sup>0</sup> - the particular condition does not matter as long as the simulation is stopped before the wave is influenced by the boundary condition).

Visualize the surface elevation. Investigate how different hill shapes, different sizes of the water gap above the hill, and different resolutions x D y D <sup>h</sup> and t influence the numerical quality of the solution.

Filename: tsunami2D\_hill.

#### **Problem 2.26: Investigate Mayavi for visualization**

Play with Mayavi code for visualizing 2D solutions of the wave equation with variable wave velocity. See if there are effective ways to visualize both the solution and the wave velocity scalar field at the same time.

Filename: tsunami2D\_hill\_mlab.

#### **Problem 2.27: Investigate visualization packages**

Create some fancy 3D visualization of the water waves *and* the subsea hill in Problem 2.25. Try to make the hill transparent. Possible visualization tools are Mayavi16, Paraview17, and OpenDX18. Filename: tsunami2D\_hill\_viz.

#### **Problem 2.28: Implement loops in compiled languages**

Extend the program from Problem 2.25 such that the loops over mesh points, inside the time loop, are implemented in compiled languages. Consider implementations in Cython, Fortran via f2py, C via Cython, C via f2py, C/C++ via Instant, and C/C++ via scipy.weave. Perform efficiency experiments to investigate the relative performance of the various implementations. It is often advantageous to normalize CPU times by the fastest method on a given mesh.

Filename: tsunami2D\_hill\_compiled.

<sup>16</sup> http://code.enthought.com/projects/mayavi/

<sup>17</sup> http://www.paraview.org/

<sup>18</sup> http://www.opendx.org/

#### **Exercise 2.29: Simulate seismic waves in 2D**

The goal of this exercise is to simulate seismic waves using the PDE model (2.138) in a 2D xz domain with geological layers. Introduce m horizontal layers of thickness hi , <sup>i</sup> <sup>D</sup> 0; : : : ; m <sup>1</sup>. Inside layer number <sup>i</sup> we have a vertical wave velocity cz;i and a horizontal wave velocity ch;i . Make a program for simulating such 2D waves. Test it on a case with 3 layers where

$$c\_{z,0} = c\_{z,1} = c\_{z,2}, \quad c\_{h,0} = c\_{h,2}, \quad c\_{h,1} \ll c\_{h,0} \text{ a.s.}$$

Let s be a localized point source at the middle of the Earth's surface (the upper boundary) and investigate how the resulting wave travels through the medium. The source can be a localized Gaussian peak that oscillates in time for some time interval. Place the boundaries far enough from the expanding wave so that the boundary conditions do not disturb the wave. Then the type of boundary condition does not matter, except that we physically need to have <sup>p</sup> <sup>D</sup> p0, where p0 is the atmospheric pressure, at the upper boundary.

Filename: seismic2D.

#### **Project 2.30: Model 3D acoustic waves in a room**

The equation for sound waves in air is derived in Sect. 2.14.5 and reads

$$p\_{ll} = c^2 \nabla^2 p.$$

where p.x; y; z; t/ is the pressure and c is the speed of sound, taken as 340 m/s. However, sound is absorbed in the air due to relaxation of molecules in the gas. A model for simple relaxation, valid for gases consisting only of one type of molecules, is a term <sup>c</sup>2sr2pt in the PDE, where s is the relaxation time. If we generate sound from, e.g., a loudspeaker in the room, this sound source must also be added to the governing equation.

The PDE with the mentioned type of damping and source then becomes

$$p\_l t = c^2 \nabla^p + c^2 \tau\_s \nabla^2 p\_l + f,\tag{2.178}$$

where f .x; y; z; t/ is the source term.

The walls can absorb some sound. A possible model is to have a "wall layer" (thicker than the physical wall) outside the room where c is changed such that some of the wave energy is reflected and some is absorbed in the wall. The absorption of energy can be taken care of by adding a damping term bpt in the equation:

$$p\_l t + b p\_l = c^2 \nabla^p + c^2 \tau\_s \nabla^2 p\_l + f \, . \tag{2.179}$$

Typically, <sup>b</sup> D <sup>0</sup> in the room and b>0 in the wall. A discontinuity in <sup>b</sup> or <sup>c</sup> will give rise to reflections. It can be wise to use a constant c in the wall to control reflections because of the discontinuity between c in the air and in the wall, while b is gradually increased as we go into the wall to avoid reflections because of rapid changes in <sup>b</sup>. At the outer boundary of the wall the condition <sup>p</sup> D <sup>0</sup> or @p=@n D <sup>0</sup> can be imposed. The waves should anyway be approximately dampened to <sup>p</sup> D <sup>0</sup> this far out in the wall layer.

There are two strategies for discretizing the <sup>r</sup>2pt term: using a center difference between times <sup>n</sup> C <sup>1</sup> and <sup>n</sup> <sup>1</sup> (if the equation is sampled at level <sup>n</sup>), or use a one-sided difference based on levels <sup>n</sup> and <sup>n</sup> <sup>1</sup>. The latter has the advantage of not leading to any equation system, while the former is second-order accurate as the scheme for the simple wave equation pt <sup>t</sup> <sup>D</sup> <sup>c</sup><sup>2</sup>r2p. To avoid an equation system, go for the one-sided difference such that the overall scheme becomes explicit and only of first order in time.

Develop a 3D solver for the specified PDE and introduce a wall layer. Test the solver with the method of manufactured solutions. Make some demonstrations where the wall reflects and absorbs the waves (reflection because of discontinuity in b and absorption because of growing b). Experiment with the impact of the s parameter.

Filename: acoustics.

#### **Project 2.31: Solve a 1D transport equation**

We shall study the wave equation

$$u\_t + cu\_x = 0, \quad x \in (0, L], \ t \in (0, T], \tag{2.180}$$

with initial condition

$$u(\mathbf{x},0) = I(\mathbf{x}), \quad \mathbf{x} \in [0,L], \tag{2.181}$$

and *one* periodic boundary condition

$$u(0,t) = u(L,t) \,. \tag{2.182}$$

This boundary condition means that what goes out of the domain at <sup>x</sup> D <sup>L</sup> comes in at <sup>x</sup> D <sup>0</sup>. Roughly speaking, we need only one boundary condition because the spatial derivative is of first order only.

**Physical interpretation** The parameter <sup>c</sup> can be constant or variable, <sup>c</sup> D c.x/. The equation (2.180) arises in *transport* problems where a quantity u, which could be temperature or concentration of some contaminant, is transported with the velocity c of a fluid. In addition to the transport imposed by "travelling with the fluid", u may also be transported by diffusion (such as heat conduction or Fickian diffusion), but we have in the model ut <sup>C</sup> cux assumed that diffusion effects are negligible, which they often are.

a) Show that under the assumption of <sup>a</sup> D const,

$$u(\mathbf{x},t) = I(\mathbf{x} - ct) \tag{2.183}$$

fulfills the PDE as well as the initial and boundary condition (provided I.0/ D I.L/).

A widely used numerical scheme for (2.180) applies a forward difference in time and a backward difference in space when c>0:

$$[D\_l^+u + cD\_\chi^-u = 0]\_l^n \,. \tag{2.184}$$

For c<0 we use a forward difference in space: ŒcD<sup>C</sup> <sup>x</sup> u<sup>n</sup> i .


$$I(\mathbf{x}) = \left[ \sin \left( \pi \frac{\mathbf{x}}{L} \right) \right]^{2n} \tag{2.185}$$

where <sup>n</sup> is an integer, typically <sup>n</sup> D <sup>5</sup>, and

$$I(\mathbf{x}) = \exp\left(-\frac{(\mathbf{x} - L/2)^2}{2\sigma \mathcal{Q}}\right). \tag{2.186}$$

Choose t <sup>D</sup> <sup>c</sup>1x; 0:9c1x; 0:5c1x.

e) The performance of the suggested numerical scheme can be investigated by analyzing the numerical dispersion relation. Analytically, we have that the *Fourier component*

$$u(\mathbf{x}, t) = e^{i(kx - \alpha t)},$$

is a solution of the PDE if ! D kc. This is the *analytical dispersion relation*. A complete solution of the PDE can be built by adding up such Fourier components with different amplitudes, where the initial condition I determines the amplitudes. The solution u is then represented by a Fourier series. A similar discrete Fourier component at .xp; tn/ is

$$u\_p^q = e^{i(k\_P \Delta x - \tilde{\alpha} n \Delta t)},$$

where in general !Q is a function of <sup>k</sup>, t, and x, and differs from the exact ! D kc.

Insert the discrete Fourier component in the numerical scheme and derive an expression for !Q, i.e., the discrete dispersion relation. Show in particular that if t=.cx/ D <sup>1</sup>, the discrete solution coincides with the exact solution at the mesh points, regardless of the mesh resolution (!). Show that if the stability condition

$$\frac{\Delta t}{c \Delta x} \le 1,$$

the discrete Fourier component cannot grow (i.e., !Q is real).

f) Write a test for your implementation where you try to use information from the numerical dispersion relation.

We shall hereafter assume that c.x/ > 0.


$$u(\mathbf{x}, t) = f(C(\mathbf{x}) - t),\tag{2.187}$$

where

$$C'(\mathbf{x}) = \frac{1}{c(\mathbf{x})},$$

is a solution of (2.180) for any differentiable function f .

i) Use the initial condition to show that an exact solution is

$$u(\mathbf{x}, t) = I(C^{-1}(C(\mathbf{x}) - t)),$$

with <sup>C</sup> <sup>1</sup> being the inverse function of <sup>C</sup> D <sup>R</sup> <sup>c</sup>1dx. Since C.x/ is an integral R x <sup>0</sup> .1=c/dx, C.x/ is monotonically increasing and there exists hence an inverse function C <sup>1</sup> with values in Œ0; L.

To compute (2.187) we need to integrate 1=c to obtain C and then compute the inverse of C.

The inverse function computation can be easily done if we first think discretely. Say we have some function <sup>y</sup> <sup>D</sup> g.x/ and seek its inverse. Plotting .xi ; yi/, where yi <sup>D</sup> g.xi/ for some mesh points xi , displays <sup>g</sup> as a function of <sup>x</sup>. The inverse function is simply x as a function of g, i.e., the curve with points.yi ; xi/. We can therefore quickly compute points at the curve of the inverse function. One way of extending these points to a continuous function is to assume a linear variation (known as linear interpolation) between the points (which actually means to draw straight lines between the points, exactly as done by a plotting program).

The function wrap2callable in scitools.std can take a set of points and return a continuous function that corresponds to linear variation between the points. The computation of the inverse of a function g on Œ0; L can then be done by

```
def inverse(g, domain, resolution=101):
    x = linspace(domain[0], domain[L], resolution)
    y = g(x)
    from scitools.std import wrap2callable
    g_inverse = wrap2callable((y, x))
    return g_inverse
```
To compute C.x/ we need to integrate 1=c, which can be done by a Trapezoidal rule. Suppose we have computed C.xi/ and need to compute C.xiC1/. Using the Trapezoidal rule with <sup>m</sup> subintervals over the integration domain Œxi ; xiC1 gives

$$C(\mathbf{x}\_{i+1}) = C(\mathbf{x}\_i) + \int\_{\mathbf{x}\_i}^{\mathbf{x}\_{i+1}} \frac{d\mathbf{x}}{c} \approx h \left( \frac{1}{2} \frac{1}{c(\mathbf{x}\_i)} + \frac{1}{2} \frac{1}{c(\mathbf{x}\_{i+1})} + \sum\_{j=1}^{m-1} \frac{1}{c(\mathbf{x}\_i + jh)} \right),\tag{2.188}$$

where <sup>h</sup> <sup>D</sup> .xiC<sup>1</sup> xi/=m is the length of the subintervals used for the integral over Œxi ; xiC1. We observe that (2.188) is a *difference equation* which we can solve by repeatedly applying (2.188) for <sup>i</sup> <sup>D</sup> 0; 1; : : : ; Nx <sup>1</sup> if a mesh x0; x; :::;xNx is prescribed. Note that C.0/ <sup>D</sup> <sup>0</sup>.


(a) c.x/ D <sup>1</sup> C sin.kx=L/, <1,

(b) c.x/ D <sup>1</sup> C I.x/, where <sup>I</sup> is given by (2.185) or (2.15).

The PDE ut <sup>C</sup> cux <sup>D</sup> <sup>0</sup> expresses that the initial condition I.x/ is transported with velocity c.x/.

Filename: advec1D.

#### **Problem 2.32: General analytical solution of a 1D damped wave equation** We consider an initial-boundary value problem for the damped wave equation:

$$\begin{aligned} u\_{tt} + b u\_t &= c^2 u\_{xx}, \quad x \in (0, L), \ t \in (0, T] \\ u(0, t) &= 0, \\ u(L, t) &= 0, \\ u(\mathbf{x}, 0) &= I(\mathbf{x}), \\ u\_l(\mathbf{x}, 0) &= V(\mathbf{x}) .\end{aligned}$$

Here, <sup>b</sup> <sup>0</sup> and <sup>c</sup> are given constants. The aim is to derive a general analytical solution of this problem. Familiarity with the method of separation of variables for solving PDEs will be assumed.

a) Seek a solution on the form u.x; t/ D X.x/T .t/. Insert this solution in the PDE and show that it leads to two differential equations for X and T :

$$T'' + bT' + \lambda T = 0, \quad c^2 X'' + \lambda X = 0,$$

with X.0/ D X.L/ D <sup>0</sup> as boundary conditions, and as a constant to be determined.

b) Show that X.x/ is on the form

$$X\_n(\infty) = C\_n \sin kx, \quad k = \frac{n\pi}{L}, \quad n = 1, 2, \ldots$$

where Cn is an arbitrary constant.

c) Under the assumption that .b=2/2 < k<sup>2</sup>, show that T .t/ is on the form

$$T\_n(t) = e^{-\frac{1}{2}bt}(a\_n \cos \alpha t + b\_n \sin \alpha t), \quad \alpha = \sqrt{k^2 - \frac{1}{4}b^2}, \quad n = 1, 2, \dots$$

The complete solution is then

$$u(\mathbf{x},t) = \sum\_{n=1}^{\infty} \sin k\lambda e^{-\frac{1}{2}bt} (A\_n \cos \alpha t + B\_n \sin \alpha t),$$

where the constants An and Bn must be computed from the initial conditions.


$$I(\mathbf{x}) = \begin{cases} a\mathbf{x}/\mathbf{x}\_0, & \mathbf{x} < \mathbf{x}\_0, \\ a(L-\mathbf{x})/(L-\mathbf{x}\_0), & \text{otherwise}. \end{cases} \tag{2.189}$$


Filename: damped\_wave1D.

#### **Problem 2.33: General analytical solution of a 2D damped wave equation**

Carry out Problem 2.32 in the 2D case: utt <sup>C</sup>but <sup>D</sup> <sup>c</sup>2.uxx <sup>C</sup>uyy/, where .x; y/ <sup>2</sup> .0; Lx/ - .0; Ly/. Assume a solution on the form u.x; y; t/ D X.x/Y .y/T .t/. Filename: damped\_wave2D.

**Open Access** This chapter is licensed under the terms of the Creative Commons Attribution 4.0 International License (http://creativecommons.org/licenses/by/4.0/), which permits use, sharing, adaptation, distribution and reproduction in any medium or format, as long as you give appropriate credit to the original author(s) and the source, provide a link to the Creative Commons license and indicate if changes were made.

The images or other third party material in this chapter are included in the chapter's Creative Commons license, unless indicated otherwise in a credit line to the material. If material is not included in the chapter's Creative Commons license and your intended use is not permitted by statutory regulation or exceeds the permitted use, you will need to obtain permission directly from the copyright holder.

# **3 Diffusion Equations**

The famous *diffusion equation*, also known as the *heat equation*, reads

$$
\frac{\partial u}{\partial t} = \alpha \frac{\partial^2 u}{\partial x^2},
$$

where u.x; t/ is the unknown function to be solved for, x is a coordinate in space, and t is time. The coefficient ˛ is the *diffusion coefficient* and determines how fast <sup>u</sup> changes in time. A quick short form for the diffusion equation is ut <sup>D</sup> ˛uxx.

Compared to the wave equation, utt <sup>D</sup> <sup>c</sup>2uxx, which looks very similar, the diffusion equation features solutions that are very different from those of the wave equation. Also, the diffusion equation makes quite different demands to the numerical methods.

Typical diffusion problems may experience rapid change in the very beginning, but then the evolution of u becomes slower and slower. The solution is usually very smooth, and after some time, one cannot recognize the initial shape of u. This is in sharp contrast to solutions of the wave equation where the initial shape is preserved in homogeneous media – the solution is then basically a moving initial condition. The standard wave equation utt <sup>D</sup> <sup>c</sup>2uxx has solutions that propagate with speed c forever, without changing shape, while the diffusion equation converges to a *stationary solution* u.x/ <sup>N</sup> as <sup>t</sup> ! 1. In this limit, ut <sup>D</sup> <sup>0</sup>, and <sup>u</sup><sup>N</sup> is governed by <sup>u</sup>N<sup>00</sup>.x/ D <sup>0</sup>. This stationary limit of the diffusion equation is called the *Laplace* equation and arises in a very wide range of applications throughout the sciences.

It is possible to solve for u.x; t/ using an explicit scheme, as we do in Sect. 3.1, but the time step restrictions soon become much less favorable than for an explicit scheme applied to the wave equation. And of more importance, since the solution u of the diffusion equation is very smooth and changes slowly, small time steps are not convenient and not required by accuracy as the diffusion process converges to a stationary state. Therefore, implicit schemes (as described in Sect. 3.2) are popular, but these require solutions of systems of algebraic equations. We shall use readymade software for this purpose, but also program some simple iterative methods. The exposition is, as usual in this book, very basic and focuses on the basic ideas and how to implement. More comprehensive mathematical treatments and classical analysis of the methods are found in lots of textbooks. A favorite of ours in this respect is the one by LeVeque [13]. The books by Strikwerda [17] and by Lapidus and Pinder [12] are also highly recommended as additional material on the topic.

H.P. Langtangen, S. Linge, *Finite Difference Computing with PDEs*,

Texts in Computational Science and Engineering 16, DOI 10.1007/978-3-319-55456-3\_3

#### **3.1 An Explicit Method for the 1D Diffusion Equation**

Explicit finite difference methods for the wave equation utt <sup>D</sup> <sup>c</sup>2uxx can be used, with small modifications, for solving ut <sup>D</sup> ˛uxx as well. The exposition below assumes that the reader is familiar with the basic ideas of discretization and implementation of wave equations from Chapter 2. Readers not familiar with the Forward Euler, Backward Euler, and Crank-Nicolson (or centered or midpoint) discretization methods in time should consult, e.g., Section 1.1 in [9].

#### **3.1.1 The Initial-Boundary Value Problem for 1D Diffusion**

To obtain a unique solution of the diffusion equation, or equivalently, to apply numerical methods, we need initial and boundary conditions. The diffusion equation goes with one initial condition u.x; 0/ D I.x/, where <sup>I</sup> is a prescribed function. One boundary condition is required at each point on the boundary, which in 1D means that u must be known, ux must be known, or some combination of them.

We shall start with the simplest boundary condition: <sup>u</sup> D <sup>0</sup>. The complete initial-boundary value diffusion problem in one space dimension can then be specified as

$$\frac{\partial u}{\partial t} = \alpha \frac{\partial^2 u}{\partial x^2} + f, \qquad \qquad \qquad \qquad \mathbf{x} \in (0, L), \ t \in (0, T] \qquad \qquad (3.1)$$

$$u(\mathbf{x},0) = I(\mathbf{x}), \quad \text{and} \quad \mathbf{x} \in [0,L] \tag{3.2}$$

$$u(0,t) = 0,\qquad\qquad t>0,\tag{3.3}$$

$$u(1,t) = 0,\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad(3.3)$$

$$u(L,t) = 0, \qquad \qquad t>0. \tag{3.4}$$

With only a first-order derivative in time, only one *initial condition* is needed, while the second-order derivative in space leads to a demand for two *boundary conditions*. We have added a source term <sup>f</sup> D f .x; t/, which is convenient when testing implementations.

Diffusion equations like (3.1) have a wide range of applications throughout physical, biological, and financial sciences. One of the most common applications is propagation of heat, where u.x; t/ represents the temperature of some substance at point x and time t. Other applications are listed in Sect. 3.8.

#### **3.1.2 Forward Euler Scheme**

The first step in the discretization procedure is to replace the domain Œ0; L - Œ0; T by a set of mesh points. Here we apply equally spaced mesh points

$$\mathbf{x}\_{i} = i\,\Delta\mathbf{x}, \quad i = 0, \ldots, N\_{\mathbf{x}},$$

and

$$t\_n = n\Delta t, \quad n = 0, \dots, N\_l \dots$$

Moreover, u<sup>n</sup> <sup>i</sup> denotes the mesh function that approximates u.xi ; tn/ for <sup>i</sup> <sup>D</sup> 0; : : : ; Nx and <sup>n</sup> <sup>D</sup> 0; : : : ; Nt . Requiring the PDE (3.1) to be fulfilled at a mesh point .xi ; tn/ leads to the equation

$$\frac{\partial}{\partial t}\mu(\mathbf{x}\_i, t\_n) = \alpha \frac{\partial^2}{\partial \mathbf{x}^2}\mu(\mathbf{x}\_i, t\_n) + f(\mathbf{x}\_i, t\_n) \,. \tag{3.5}$$

The next step is to replace the derivatives by finite difference approximations. The computationally simplest method arises from using a forward difference in time and a central difference in space:

$$\left[D\_{\boldsymbol{l}}^{+}\boldsymbol{u}=\boldsymbol{\alpha}D\_{\boldsymbol{x}}D\_{\boldsymbol{x}}\boldsymbol{u}+\boldsymbol{f}\right]\_{\boldsymbol{l}}^{\boldsymbol{n}}.\tag{3.6}$$

Written out,

$$\frac{u\_{i}^{n+1} - u\_{i}^{n}}{\Delta t} = \alpha \frac{u\_{i+1}^{n} - 2u\_{i}^{n} + u\_{i-1}^{n}}{\Delta x^{2}} + f\_{i}^{n} \,. \tag{3.7}$$

We have turned the PDE into algebraic equations, also often called discrete equations. The key property of the equations is that they are algebraic, which makes them easy to solve. As usual, we anticipate that u<sup>n</sup> <sup>i</sup> is already computed such that u<sup>n</sup>C<sup>1</sup> <sup>i</sup> is the only unknown in (3.7). Solving with respect to this unknown is easy:

$$u\_{i}^{n+1} = u\_{i}^{n} + F\left(u\_{i+1}^{n} - 2u\_{i}^{n} + u\_{i-1}^{n}\right) + \Delta t f\_{i}^{n},\tag{3.8}$$

where we have introduced the *mesh Fourier number*:

$$F = \alpha \frac{\Delta t}{\Delta x^2}.\tag{3.9}$$

## F **is the key parameter in the discrete diffusion equation**

Note that F is a *dimensionless* number that lumps the key physical parameter in the problem, ˛, and the discretization parameters x and t into a single parameter. Properties of the numerical method are critically dependent upon the value of F (see Sect. 3.3 for details).

The computational algorithm then becomes

	- (a) apply (3.8) for all the internal spatial points <sup>i</sup> <sup>D</sup> 1; : : : ; Nx <sup>1</sup> (b) set the boundary values u<sup>n</sup>C<sup>1</sup> <sup>i</sup> <sup>D</sup> <sup>0</sup> for <sup>i</sup> <sup>D</sup> <sup>0</sup> and <sup>i</sup> <sup>D</sup> Nx

The algorithm is compactly and fully specified in Python:

```
import numpy as np
x = np.linspace(0, L, Nx+1) # mesh points in space
dx = x[1] - x[0]
t = np.linspace(0, T, Nt+1) # mesh points in time
dt = t[1] - t[0]
F = a*dt/dx**2
u = np.zeros(Nx+1) # unknown u at new time level
u_n = np.zeros(Nx+1) # u at the previous time level
```

```
# Set initial condition u(x,0) = I(x)
for i in range(0, Nx+1):
    u_n[i] = I(x[i])
for n in range(0, Nt):
    # Compute u at inner mesh points
    for i in range(1, Nx):
        u[i] = u_n[i] + F*(u_n[i-1] - 2*u_n[i] + u_n[i+1]) + \
           dt*f(x[i], t[n])
    # Insert boundary conditions
    u[0] = 0; u[Nx]=0
    # Update u_n before next step
    u_n[:]= u
```
Note that we use a for ˛ in the code, motivated by easy visual mapping between the variable name and the mathematical symbol in formulas.

We need to state already now that the shown algorithm does not produce meaningful results unless <sup>F</sup> 1=2. Why is explained in Sect. 3.3.

#### **3.1.3 Implementation**

The file diffu1D\_u0.py contains a complete function solver\_FE\_simple for solving the 1D diffusion equation with <sup>u</sup> D <sup>0</sup> on the boundary as specified in the algorithm above:

```
import numpy as np
def solver_FE_simple(I, a, f, L, dt, F, T):
    """
    Simplest expression of the computational algorithm
    using the Forward Euler method and explicit Python loops.
    For this method F <= 0.5 for stability.
    """
    import time; t0 = time.clock() # For measuring the CPU time
    Nt = int(round(T/float(dt)))
    t = np.linspace(0, Nt*dt, Nt+1) # Mesh points in time
    dx = np.sqrt(a*dt/F)
    Nx = int(round(L/dx))
    x = np.linspace(0, L, Nx+1) # Mesh points in space
    # Make sure dx and dt are compatible with x and t
    dx = x[1] - x[0]
    dt = t[1] - t[0]
    u = np.zeros(Nx+1)
    u_n = np.zeros(Nx+1)
    # Set initial condition u(x,0) = I(x)
    for i in range(0, Nx+1):
        u_n[i] = I(x[i])
```

```
for n in range(0, Nt):
    # Compute u at inner mesh points
    for i in range(1, Nx):
        u[i] = u_n[i] + F*(u_n[i-1] - 2*u_n[i] + u_n[i+1]) + \
               dt*f(x[i], t[n])
    # Insert boundary conditions
    u[0] = 0; u[Nx]=0
    # Switch variables before next step
    #u_n[:] = u # safe, but slow
    u_n, u = u, u_n
t1 = time.clock()
return u_n, x, t, t1-t0 # u_n holds latest u
```
A faster alternative is available in the function solver\_FE, which adds the possibility of solving the finite difference scheme by vectorization. The vectorized version replaces the explicit loop

```
for i in range(1, Nx):
    u[i] = u_n[i] + F*(u_n[i-1] - 2*u_n[i] + u_n[i+1]) \
           + dt*f(x[i], t[n])
```
by arithmetics on displaced slices of the u array:

```
u[1:Nx] = u_n[1:Nx] + F*(u_n[0:Nx-1] - 2*u_n[1:Nx] + u_n[2:Nx+1]) \
          + dt*f(x[1:Nx], t[n])
# or
u[1:-1] = u_n[1:-1] + F*(u_n[0:-2] - 2*u_n[1:-1] + u_n[2:]) \
          + dt*f(x[1:-1], t[n])
```
For example, the vectorized version runs 70 times faster than the scalar version in a case with 100 time steps and a spatial mesh of 10<sup>5</sup> cells.

The solver\_FE function also features a callback function such that the user can process the solution at each time level. The callback function looks like user\_action(u, x, t, n), where u is the array containing the solution at time level n, x holds all the spatial mesh points, while t holds all the temporal mesh points. The solver\_FE function is very similar to solver\_FE\_simple above:

```
def solver_FE(I, a, f, L, dt, F, T,
              user_action=None, version='scalar'):
    """
    Vectorized implementation of solver_FE_simple.
    """
    import time; t0 = time.clock() # for measuring the CPU time
```

```
Nt = int(round(T/float(dt)))
t = np.linspace(0, Nt*dt, Nt+1) # Mesh points in time
dx = np.sqrt(a*dt/F)
Nx = int(round(L/dx))
x = np.linspace(0, L, Nx+1) # Mesh points in space
# Make sure dx and dt are compatible with x and t
dx = x[1] - x[0]
dt = t[1] - t[0]
u = np.zeros(Nx+1) # solution array
u_n = np.zeros(Nx+1) # solution at t-dt
# Set initial condition
for i in range(0,Nx+1):
    u_n[i] = I(x[i])
if user_action is not None:
    user_action(u_n, x, t, 0)
for n in range(0, Nt):
    # Update all inner points
    if version == 'scalar':
        for i in range(1, Nx):
            u[i] = u_n[i] +\
                   F*(u_n[i-1] - 2*u_n[i] + u_n[i+1]) +\
                   dt*f(x[i], t[n])
    elif version == 'vectorized':
        u[1:Nx] = u_n[1:Nx]+ \
                  F*(u_n[0:Nx-1] - 2*u_n[1:Nx] + u_n[2:Nx+1]) +\
                  dt*f(x[1:Nx], t[n])
    else:
        raise ValueError('version=%s' % version)
    # Insert boundary conditions
    u[0] = 0; u[Nx]=0
    if user_action is not None:
        user_action(u, x, t, n+1)
    # Switch variables before next step
    u_n, u = u, u_n
t1 = time.clock()
return t1-t0
```
#### **3.1.4 Verification**

**Exact solution of discrete equations** Before thinking about running the functions in the previous section, we need to construct a suitable test example for verification. It appears that a manufactured solution that is linear in time and at most quadratic in space fulfills the Forward Euler scheme exactly. With the restriction that <sup>u</sup> D <sup>0</sup> for <sup>x</sup> D 0; L, we can try the solution

$$u(x,t) = \mathfrak{Stx}(L-x)\,.$$

Inserted in the PDE, it requires a source term

$$f(\mathbf{x}, t) = 10\alpha t + 5\mathbf{x}(L - \mathbf{x})\,.$$

With the formulas from Appendix A.4 we can easily check that the manufactured u fulfills the scheme:

$$\begin{aligned} [D\_l^+ u = \alpha D\_\times D\_\times u + f]\_l^n &= [5\mathbf{x}(L-\mathbf{x})D\_l^+ t = 5ta D\_\times D\_\times (\mathbf{x}L - \mathbf{x}^2) \\ &+ 10at + 5\mathbf{x}(L-\mathbf{x})]\_l^n \\ &= [5\mathbf{x}(L-\mathbf{x}) = 5ta(-2) + 10at + 5\mathbf{x}(L-\mathbf{x})]\_l^n, \end{aligned}$$

which is a 0=0 expression. The computation of the source term, given any u, is easily automated with sympy:

```
import sympy as sym
x, t, a, L = sym.symbols('x t a L')
u = x*(L-x)*5*t
def pde(u):
    return sym.diff(u, t) - a*sym.diff(u, x, x)
f = sym.simplify(pde(u))
```
Now we can choose any expression for u and automatically get the suitable source term f. However, the manufactured solution u will in general not be exactly reproduced by the scheme: only constant and linear functions are differentiated correctly by a forward difference, while only constant, linear, and quadratic functions are differentiated exactly by a ŒDxDxu<sup>n</sup> <sup>i</sup> difference.

The numerical code will need to access the u and f above as Python functions. The exact solution is wanted as a Python function u\_exact(x, t), while the source term is wanted as f(x, t). The parameters a and L in u and f above are symbols and must be replaced by float objects in a Python function. This can be done by redefining a and L as float objects and performing substitutions of symbols by numbers in u and f. The appropriate code looks like this:

```
a = 0.5
L = 1.5
u_exact = sym.lambdify(
    [x, t], u.subs('L', L).subs('a', a), modules='numpy')
f = sym.lambdify(
    [x, t], f.subs('L', L).subs('a', a), modules='numpy')
I = lambda x: u_exact(x, 0)
```
Here we also make a function I for the initial condition.

The idea now is that our manufactured solution should be exactly reproduced by the code (to machine precision). For this purpose we make a test function for comparing the exact and numerical solutions at the end of the time interval:

```
def test_solver_FE():
    # Define u_exact, f, I as explained above
    dx = L/3 # 3 cells
    F = 0.5
    dt = F*dx**2
    u, x, t, cpu = solver_FE_simple(
        I=I, a=a, f=f, L=L, dt=dt, F=F, T=2)
    u_e = u_exact(x, t[-1])
    diff = abs(u_e - u).max()
    tol = 1E-14
    assert diff < tol, 'max diff solver_FE_simple: %g' % diff
    u, x, t, cpu = solver_FE(
        I=I, a=a, f=f, L=L, dt=dt, F=F, T=2,
        user_action=None, version='scalar')
    u_e = u_exact(x, t[-1])
    diff = abs(u_e - u).max()
    tol = 1E-14
    assert diff < tol, 'max diff solver_FE, scalar: %g' % diff
    u, x, t, cpu = solver_FE(
        I=I, a=a, f=f, L=L, dt=dt, F=F, T=2,
        user_action=None, version='vectorized')
    u_e = u_exact(x, t[-1])
    diff = abs(u_e - u).max()
    tol = 1E-14
    assert diff < tol, 'max diff solver_FE, vectorized: %g' % diff
```
# **The critical value** F <sup>D</sup> 0:5

We emphasize that the value F=0.5 is critical: the tests above will fail if F has a larger value. This is because the Forward Euler scheme is unstable for F > 1=2.

The reader may wonder if <sup>F</sup> D 1=2 is safe or if F < 1=2 should be required. Experiments show that <sup>F</sup> <sup>D</sup> 1=2 works fine for ut <sup>D</sup> ˛uxx, so there is no accumulation of rounding errors in this case and hence no need to introduce any safety factor to keep F away from the limiting value 0.5.

**Checking convergence rates** If our chosen exact solution does not satisfy the discrete equations exactly, we are left with checking the convergence rates, just as we did previously for the wave equation. However, with the Euler scheme here, we have different accuracies in time and space, since we use a second order approximation to the spatial derivative and a first order approximation to the time derivative. Thus, we must expect different convergence rates in time and space. For the numerical error,

$$E = C\_l \Delta t^r + C\_\chi \Delta x^p,$$

we should get convergence rates <sup>r</sup> <sup>D</sup> <sup>1</sup> and <sup>p</sup> <sup>D</sup> <sup>2</sup> (Ct and Cx are unknown constants). As previously, in Sect. 2.2.3, we simplify matters by introducing a single discretization parameter h:

$$h = \Delta t, \quad \Delta \mathbf{x} = Kh^{r/p},$$

where K is any constant. This allows us to factor out only *one* discretization parameter h from the formula:

$$E = C\_l h + C\_\times (Kh^{r/p})^p = \tilde{C}h^r, \quad \tilde{C} = C\_l + C\_s K^r.$$

The computed rate r should approach 1 with increasing resolution.

It is tempting, for simplicity, to choose <sup>K</sup> D <sup>1</sup>, which gives x D hr=p, expected to be <sup>p</sup>t. However, we have to control the stability requirement: <sup>F</sup> <sup>1</sup> 2 , which means

$$
\frac{\alpha \Delta t}{\Delta x^2} \le \frac{1}{2} \quad \Rightarrow \quad \Delta x \ge \sqrt{2\alpha}h^{1/2},
$$

implying that <sup>K</sup> D <sup>p</sup>2˛ is our choice in experiments where we lie on the stability limit <sup>F</sup> D 1=2.

#### **3.1.5 Numerical Experiments**

When a test function like the one above runs silently without errors, we have some evidence for a correct implementation of the numerical method. The next step is to do some experiments with more interesting solutions.

We target a scaled diffusion problem where x=L is a new spatial coordinate and ˛t=L<sup>2</sup> is a new time coordinate. The source term f is omitted, and u is scaled by max<sup>x</sup>2Œ0;L <sup>j</sup>I.x/<sup>j</sup> (see Section 3.2 in [11] for details). The governing PDE is then

$$
\frac{\partial u}{\partial t} = \frac{\partial^2 u}{\partial x^2},
$$

in the spatial domain Œ0; L, with boundary conditions u.0/ D u.1/ D <sup>0</sup>. Two initial conditions will be tested: a discontinuous plug,

$$I(\mathbf{x}) = \begin{cases} 0, & |\mathbf{x} - L/2| > 0.1 \\ 1, & \text{otherwise} \end{cases}$$

and a smooth Gaussian function,

$$I(\mathbf{x}) = e^{-\frac{1}{2\sigma^2}(\mathbf{x} - L/2)^2}$$

:

The functions plug and gaussian in diffu1D\_u0.py run the two cases, respectively:

```
def plug(scheme='FE', F=0.5, Nx=50):
    L = 1.
    a = 1.
    T = 0.1
    # Compute dt from Nx and F
    dx = L/Nx; dt = F/a*dx**2
```

```
def I(x):
        """Plug profile as initial condition."""
        if abs(x-L/2.0) > 0.1:
            return 0
        else:
            return 1
    cpu = viz(I, a, L, dt, F, T,
              umin=-0.1, umax=1.1,
              scheme=scheme, animate=True, framefiles=True)
    print 'CPU time:', cpu
def gaussian(scheme='FE', F=0.5, Nx=50, sigma=0.05):
    L = 1.
    a = 1.
    T = 0.1
    # Compute dt from Nx and F
    dx = L/Nx; dt = F/a*dx**2
    def I(x):
        """Gaussian profile as initial condition."""
        return exp(-0.5*((x-L/2.0)**2)/sigma**2)
    u, cpu = viz(I, a, L, dt, F, T,
                 umin=-0.1, umax=1.1,
                 scheme=scheme, animate=True, framefiles=True)
    print 'CPU time:', cpu
```
These functions make use of the function viz for running the solver and visualizing the solution using a callback function with plotting:

```
def viz(I, a, L, dt, F, T, umin, umax,
        scheme='FE', animate=True, framefiles=True):
    def plot_u(u, x, t, n):
        plt.plot(x, u, 'r-', axis=[0, L, umin, umax],
                 title='t=%f' % t[n])
        if framefiles:
            plt.savefig('tmp_frame%04d.png' % n)
        if t[n] == 0:
            time.sleep(2)
        elif not framefiles:
            # It takes time to write files so pause is needed
            # for screen only animation
            time.sleep(0.2)
    user_action = plot_u if animate else lambda u,x,t,n: None
    cpu = eval('solver_'+scheme)(I, a, L, dt, F, T,
                                  user_action=user_action)
    return cpu
```
Notice that this viz function stores all the solutions in a list solutions in the callback function. Modern computers have hardly any problem with storing a lot

**Fig. 3.1** Forward Euler scheme for <sup>F</sup> D 0:5

of such solutions for moderate values of Nx in 1D problems, but for 2D and 3D problems, this technique cannot be used and solutions must be stored in files.

Our experiments employ a time step t D 0:0002 and simulate for <sup>t</sup> 2 Œ0; 0:1. First we try the highest value of <sup>F</sup> : <sup>F</sup> D 0:5. This resolution corresponds to Nx <sup>D</sup> <sup>50</sup>. A possible terminal command is


The u.x; t/ curve as a function of x is shown in Fig. 3.1 at four time levels.

**Movie 1** https://raw.githubusercontent.com/hplgit/fdm-book/master/doc/pub/book/html/ mov-diffu/diffu1D\_u0\_FE\_plug/movie.ogg

We see that the curves have saw-tooth waves in the beginning of the simulation. This non-physical noise is smoothed out with time, but solutions of the diffusion equations are known to be smooth, and this numerical solution is definitely not smooth. Lowering <sup>F</sup> helps: <sup>F</sup> 0:25 gives a smooth solution, see Fig. 3.2 (and a movie1).

<sup>1</sup> http://tinyurl.com/gokgkov/mov-diffu/diffu1D\_u0\_FE\_plug\_F025/movie.ogg

**Fig. 3.2** Forward Euler scheme for <sup>F</sup> D 0:25

Increasing <sup>F</sup> slightly beyond the limit 0.5, to <sup>F</sup> D 0:51, gives growing, nonphysical instabilities, as seen in Fig. 3.3.

Instead of a discontinuous initial condition we now try the smooth Gaussian function for I.x/. A simulation for <sup>F</sup> D 0:5 is shown in Fig. 3.4. Now the numerical solution is smooth for all times, and this is true for any <sup>F</sup> 0:5.

Experiments with these two choices of I.x/ reveal some important observations:


#### **3.2 Implicit Methods for the 1D Diffusion Equation**

Simulations with the Forward Euler scheme show that the time step restriction, <sup>F</sup> <sup>1</sup> <sup>2</sup> , which means t x2=.2˛/, may be relevant in the beginning of the diffusion process, when the solution changes quite fast, but as time increases, the process slows down, and a small t may be inconvenient. With *implicit schemes*, which lead to coupled systems of linear equations to be solved at each time level,

**Fig. 3.3** Forward Euler scheme for <sup>F</sup> D 0:51

any size of t is possible (but the accuracy decreases with increasing t). The Backward Euler scheme, derived and implemented below, is the simplest implicit scheme for the diffusion equation.

#### **3.2.1 Backward Euler Scheme**

In (3.5), we now apply a backward difference in time, but the same central difference in space:

$$\left[D\_l^-u = D\_{\underline{x}}D\_{\underline{x}}u + f\right]\_l^n,\tag{3.10}$$

which written out reads

$$\frac{u\_i^n - u\_i^{n-1}}{\Delta t} = \alpha \frac{u\_{i+1}^n - 2u\_i^n + u\_{i-1}^n}{\Delta x^2} + f\_i^n \,. \tag{3.11}$$

Now we assume u<sup>n</sup><sup>1</sup> <sup>i</sup> is already computed, but that all quantities at the "new" time level n are unknown. This time it is not possible to solve with respect to u<sup>n</sup> <sup>i</sup> because this value couples to its neighbors in space, u<sup>n</sup> <sup>i</sup><sup>1</sup> and <sup>u</sup><sup>n</sup> <sup>i</sup>C<sup>1</sup>, which are also unknown. Let us examine this fact for the case when Nx <sup>D</sup> <sup>3</sup>. Equation (3.11) written for

**Fig. 3.4** Forward Euler scheme for <sup>F</sup> D 0:5

<sup>i</sup> D 1; : : : ; N x <sup>1</sup> D 1; 2 becomes

$$\frac{u\_1^n - u\_1^{n-1}}{\Delta t} = \alpha \frac{u\_2^n - 2u\_1^n + u\_0^n}{\Delta x^2} + f\_1^n \tag{3.12}$$

$$\frac{u\_2^n - u\_2^{n-1}}{\Delta t} = \alpha \frac{u\_3^n - 2u\_2^n + u\_1^n}{\Delta x^2} + f\_2^n \,. \tag{3.13}$$

The boundary values u<sup>n</sup> <sup>0</sup> and u<sup>n</sup> <sup>3</sup> are known as zero. Collecting the unknown new values u<sup>n</sup> <sup>1</sup> and u<sup>n</sup> <sup>2</sup> on the left-hand side and multiplying by t gives

$$\left(1+2F\right)u\_1^n - Fu\_2^n = u\_1^{n-1} + \Delta t f\_1^n,\tag{3.14}$$

$$-F u\_1^n + \left(1 + 2F\right) u\_2^n = u\_2^{n-1} + \Delta t f\_2^n. \tag{3.15}$$

This is a coupled <sup>2</sup> - 2 system of algebraic equations for the unknowns u<sup>n</sup> <sup>1</sup> and u<sup>n</sup> 2 . The equivalent matrix form is

$$
\begin{pmatrix} 1+2F & -F \\ -F & 1+2F \end{pmatrix} \begin{pmatrix} u\_1^n \\ u\_2^n \end{pmatrix} = \begin{pmatrix} u\_1^{n-1} + \Delta t f\_1^n \\ u\_2^{n-1} + \Delta t f\_2^n \end{pmatrix}.
$$

#### **Terminology: implicit vs. explicit methods**

Discretization methods that lead to a coupled system of equations for the unknown function at a new time level are said to be *implicit methods*. The counterpart, *explicit methods*, refers to discretization methods where there is a simple explicit formula for the values of the unknown function at each of the spatial mesh points at the new time level. From an implementational point of view, implicit methods are more comprehensive to code since they require the solution of coupled equations, i.e., a matrix system, at each time level. With explicit methods we have a closed-form formula for the value of the unknown at each mesh point.

Very often explicit schemes have a restriction on the size of the time step that can be relaxed by using implicit schemes. In fact, implicit schemes are frequently unconditionally stable, so the size of the time step is governed by accuracy and not by stability. This is the great advantage of implicit schemes.

In the general case, (3.11) gives rise to a coupled .Nx 1/ - .Nx 1/ system of algebraic equations for all the unknown u<sup>n</sup> <sup>i</sup> at the interior spatial points <sup>i</sup> <sup>D</sup> 1; : : : ; Nx <sup>1</sup>. Collecting the unknowns on the left-hand side, (3.11) can be written

$$-F u\_{i-1}^{n} + \left(1 + 2F\right) u\_i^n - F u\_{i+1}^{n} = u\_{i-1}^{n-1},\tag{3.16}$$

for <sup>i</sup> <sup>D</sup> 1; : : : ; Nx <sup>1</sup>. One can either view these equations as a system where the un <sup>i</sup> values at the internal mesh points, <sup>i</sup> <sup>D</sup> 1; : : : ; Nx <sup>1</sup>, are unknown, or we may append the boundary values u<sup>n</sup> <sup>0</sup> and u<sup>n</sup> Nx to the system. In the latter case, all <sup>u</sup><sup>n</sup> <sup>i</sup> for <sup>i</sup> <sup>D</sup> 0; : : : ; Nx are considered unknown, and we must add the boundary equations to the Nx <sup>1</sup> equations in (3.16):

$$
\mu\_0'' = 0,\tag{3.17}
$$

$$
u\_{N\_x}^n = 0.\tag{3.18}$$

A coupled system of algebraic equations can be written on matrix form, and this is important if we want to call up ready-made software for solving the system. The equations (3.16) and (3.17)–(3.18) correspond to the matrix equation

$$AU = b$$

where <sup>U</sup> D .u<sup>n</sup> 0;:::;u<sup>n</sup> Nx /, and the matrix A has the following structure:

$$A = \begin{pmatrix} A\_{0,0} & A\_{0,1} & 0 & \cdots & \cdots & \cdots & \cdots & \cdots & 0 \\ A\_{1,0} & A\_{1,1} & A\_{1,2} & \ddots & & & & & \vdots \\ 0 & A\_{2,1} & A\_{2,2} & A\_{2,3} & \ddots & & & & \vdots \\ \vdots & \ddots & \ddots & \ddots & \ddots & 0 & & & \vdots \\ \vdots & & \ddots & \ddots & \ddots & \ddots & \ddots & \vdots \\ \vdots & & & & & 0 & A\_{i,i-1} & A\_{i,i} & A\_{i,i+1} & \ddots & \vdots \\ \vdots & & & & & & \ddots & \ddots & \ddots & 0 \\ \vdots & & & & & & \ddots & \ddots & \ddots & \ddots & 0 \\ \vdots & & & & & & \ddots & \ddots & \ddots & A\_{N\_x-1, N\_x} \\ \vdots & & & & & & & & 0 \\ 0 & \cdots & \cdots & \cdots & \cdots & \cdots & 0 & A\_{N\_x, N\_x-1} & A\_{N\_x, N\_x} \end{pmatrix} \cdot \tag{3.19}$$

The nonzero elements are given by

$$A\_{i,i-1} = -F \tag{3.20}$$

$$A\_{i,i} = 1 + 2F \tag{3.21}$$

$$A\_{i,i+1} = -F \tag{3.22}$$

in the equations for internal points, <sup>i</sup> <sup>D</sup> 1; : : : ; Nx <sup>1</sup>. The first and last equation correspond to the boundary condition, where we know the solution, and therefore we must have

$$A\_{0,0} = 1,\tag{3.23}$$

$$A\_{0,1} = 0,\tag{3.24}$$

$$A\_{N\_x, N\_x - 1} = 0,\tag{3.25}$$

$$A\_{N\_{\lambda}, N\_{\lambda}} = 1\,. \tag{3.26}$$

The right-hand side b is written as

$$b = \begin{pmatrix} b\_0 \\ b\_1 \\ \vdots \\ b\_i \\ b\_i \\ \vdots \\ b\_{N\_\chi} \end{pmatrix} \tag{3.27}$$

with

$$b\_0 = 0,\tag{3.28}$$

$$\begin{array}{ccccc} b\_0 = 0, & & & \\ & \dots & \dots & \dots & \dots & \\ & & \dots & & \\ & & & \dots & \\ & & & & \dots \end{array} \tag{3.28}$$

$$b\_i = u\_i^{n-1}, \quad i = 1, \ldots, N\_x - 1,\tag{3.29}$$

$$b\_{N\_\chi} = 0.\tag{3.30}$$

We observe that the matrix A contains quantities that do not change in time. Therefore, A can be formed once and for all before we enter the recursive formulas for the time evolution. The right-hand side b, however, must be updated at each time step. This leads to the following computational algorithm, here sketched with Python code:

```
x = np.linspace(0, L, Nx+1) # mesh points in space
dx = x[1] - x[0]
t = np.linspace(0, T, N+1) # mesh points in time
u = np.zeros(Nx+1) # unknown u at new time level
u_n = np.zeros(Nx+1) # u at the previous time level
```

```
# Data structures for the linear system
A = np.zeros((Nx+1, Nx+1))
b = np.zeros(Nx+1)
for i in range(1, Nx):
    A[i,i-1] = -F
    A[i,i+1] = -F
    A[i,i] = 1 + 2*F
A[0,0] = A[Nx,Nx]=1
# Set initial condition u(x,0) = I(x)
for i in range(0, Nx+1):
    u_n[i] = I(x[i])
import scipy.linalg
for n in range(0, Nt):
    # Compute b and solve linear system
    for i in range(1, Nx):
        b[i]=-u_n[i]
    b[0] = b[Nx]=0
    u[:] = scipy.linalg.solve(A, b)
    # Update u_n before next step
    u_n[:] = u
```
Regarding verification, the same considerations apply as for the Forward Euler method (Sect. 3.1.4).

#### **3.2.2 Sparse Matrix Implementation**

We have seen from (3.19) that the matrix A is tridiagonal. The code segment above used a full, dense matrix representation of A, which stores a lot of values we know are zero beforehand, and worse, the solution algorithm computes with all these zeros. With Nx <sup>C</sup> <sup>1</sup> unknowns, the work by the solution algorithm is <sup>1</sup> <sup>3</sup> .Nx <sup>C</sup> 1/3 and the storage requirements .Nx <sup>C</sup> 1/2. By utilizing the fact that <sup>A</sup> is tridiagonal and employing corresponding software tools that work with the three diagonals, the work and storage demands can be proportional to Nx only. This leads to a dramatic improvement: with Nx <sup>D</sup> <sup>200</sup>, which is a realistic resolution, the code runs about 40,000 times faster and reduces the storage to just 1.5 %! It is no doubt that we should take advantage of the fact that A is tridiagonal.

The key idea is to apply a data structure for a tridiagonal or sparse matrix. The scipy.sparse package has relevant utilities. For example, we can store only the nonzero diagonals of a matrix. The package also has linear system solvers that operate on sparse matrix data structures. The code below illustrates how we can store only the main diagonal and the upper and lower diagonals.

```
# Representation of sparse matrix and right-hand side
main = np.zeros(Nx+1)
lower = np.zeros(Nx)
upper = np.zeros(Nx)
b = np.zeros(Nx+1)
# Precompute sparse matrix
main[:] = 1 + 2*F
lower[:] = -F
upper[:] = -F
# Insert boundary conditions
main[0] = 1
main[Nx]=1
A = scipy.sparse.diags(
    diagonals=[main, lower, upper],
    offsets=[0, -1, 1], shape=(Nx+1, Nx+1),
    format='csr')
print A.todense() # Check that A is correct
# Set initial condition
for i in range(0,Nx+1):
    u_n[i] = I(x[i])
for n in range(0, Nt):
    b = u_n
    b[0] = b[-1] = 0.0 # boundary conditions
    u[:] = scipy.sparse.linalg.spsolve(A, b)
    u_n[:] = u
```
The scipy.sparse.linalg.spsolve function utilizes the sparse storage structure of A and performs, in this case, a very efficient Gaussian elimination solve.

The program diffu1D\_u0.py contains a function solver\_BE, which implements the Backward Euler scheme sketched above. As mentioned in Sect. 3.1.2, the functions plug and gaussian run the case with I.x/ as a discontinuous plug or a smooth Gaussian function. All experiments point to two characteristic features of the Backward Euler scheme: 1) it is always stable, and 2) it always gives a smooth, decaying solution.

#### **3.2.3 Crank-Nicolson Scheme**

The idea in the Crank-Nicolson scheme is to apply centered differences in space and time, combined with an average in time. We demand the PDE to be fulfilled at the spatial mesh points, but midway between the points in the time mesh:

$$
\frac{
\partial
}{
\partial t
}u
\left(\mathbf{x}\_i, t\_{n+\frac{1}{2}}\right) = \alpha \frac{
\partial^2
}{
\partial \boldsymbol{\chi}^2
}u
\left(\mathbf{x}\_i, t\_{n+\frac{1}{2}}\right) + f\left(\mathbf{x}\_i, t\_{n+\frac{1}{2}}\right),
$$

for <sup>i</sup> <sup>D</sup> 1; : : : ; Nx <sup>1</sup> and <sup>n</sup> <sup>D</sup> 0; : : : ; Nt <sup>1</sup>.

With centered differences in space and time, we get

$$\left[D\_t u = \alpha D\_\times D\_\times u + f\right]\_i^{n + \frac{1}{2}}\dots$$

On the right-hand side we get an expression

$$\frac{1}{\Delta x^2} \left( u\_{i-1}^{n+\frac{1}{2}} - 2u\_i^{n+\frac{1}{2}} + u\_{i+1}^{n+\frac{1}{2}} \right) + f\_i^{n+\frac{1}{2}} \dots$$

This expression is problematic since u nC 1 2 <sup>i</sup> is not one of the unknowns we compute. A possibility is to replace u nC 1 2 <sup>i</sup> by an arithmetic average:

$$
u\_i^{n + \frac{1}{2}} \approx \frac{1}{2} \left( \boldsymbol{u}\_i^n + \boldsymbol{u}\_i^{n+1} \right) \ . $$

In the compact notation, we can use the arithmetic average notation u<sup>t</sup> :

$$\left[D\_t u = \alpha D\_x D\_x \overline{u}' + f\right]\_i^{n + \frac{1}{2}}.$$

We can also use an average for <sup>f</sup> <sup>n</sup><sup>C</sup> <sup>1</sup> 2 <sup>i</sup> :

$$[D\_t u = \alpha D\_x D\_x \overline{u}^t + \overline{f}^t]\_i^{n + \frac{1}{2}} \dots$$

After writing out the differences and average, multiplying by t, and collecting all unknown terms on the left-hand side, we get

$$\begin{split} u\_{i}^{n+1} - \frac{1}{2} F \left( u\_{i-1}^{n+1} - 2u\_{i}^{n+1} + u\_{i+1}^{n+1} \right) &= u\_{i}^{n} + \frac{1}{2} F \left( u\_{i-1}^{n} - 2u\_{i}^{n} + u\_{i+1}^{n} \right) \\ &+ \frac{1}{2} f\_{i}^{n+1} + \frac{1}{2} f\_{i}^{n} \,. \end{split} \tag{3.31}$$

Also here, as in the Backward Euler scheme, the new unknowns u<sup>n</sup>C<sup>1</sup> <sup>i</sup><sup>1</sup> , <sup>u</sup><sup>n</sup>C<sup>1</sup> <sup>i</sup> , and u<sup>n</sup>C<sup>1</sup> <sup>i</sup>C<sup>1</sup> are coupled in a linear system AU <sup>D</sup> <sup>b</sup>, where <sup>A</sup> has the same structure as in (3.19), but with slightly different entries:

$$A\_{i,i-1} = -\frac{1}{2}F\tag{3.32}$$

$$A\_{i,i} = 1 + F \tag{3.33}$$

$$A\_{i,i+1} = -\frac{1}{2}F\tag{3.34}$$

in the equations for internal points, <sup>i</sup> <sup>D</sup> 1; : : : ; Nx <sup>1</sup>. The equations for the boundary points correspond to

$$A\_{0,0} = 1,\tag{3.35}$$

$$A\_{0,1} = 0,\tag{3.36}$$

$$A\_{N\_\lambda, N\_\lambda - 1} = 0,\tag{3.37}$$

$$A\_{N\_x, N\_x} = 1\,. \tag{3.38}$$

The right-hand side b has entries

$$b\_0 = 0,\tag{3.39}$$

$$b\_i = u\_i^{n-1} + \frac{1}{2} \left( f\_i^n + f\_i^{n+1} \right), \quad i = 1, \ldots, N\_\chi - 1,\tag{3.40}$$

$$b\_{N\_\chi} = 0.\tag{3.41}$$

When verifying some implementation of the Crank-Nicolson scheme by convergence rate testing, one should note that the scheme is second order accurate in both space and time. The numerical error then reads

$$E = C\_l \Delta t^r + C\_x \Delta x^r,$$

where <sup>r</sup> <sup>D</sup> <sup>2</sup> (Ct and Cx are unknown constants, as before). When introducing a single discretization parameter, we may now simply choose

$$h = \Delta x = \Delta t,$$

which gives

$$E = C\_l h^r + C\_\chi h^r = (C\_l + C\_\chi)h^r,$$

where r should approach 2 as resolution is increased in the convergence rate computations.

# **3.2.4 The Unifying Rule**

For the equation

$$\frac{\partial u}{\partial t} = G(u),$$

where G.u/ is some spatial differential operator, the --rule looks like

$$\frac{u\_i^{n+1} - u\_i^n}{\Delta t} = \theta G(u\_i^{n+1}) + (1 - \theta)G(u\_i^n) \dots$$

The important feature of this time discretization scheme is that we can implement one formula and then generate a family of well-known and widely used schemes:


In the compact difference notation, we write the rule as

$$[\overline{D}\_l u = a D\_{\underline{\times}} D\_{\underline{\times}} u]^{n+\theta} . $$

We have that tnC- <sup>D</sup> tnC<sup>1</sup> <sup>C</sup> .1 -/tn. Applied to the 1D diffusion problem, the --rule gives

$$\begin{split} \frac{u\_{i}^{n+1} - u\_{i}^{n}}{\Delta t} &= \alpha \left( \theta \frac{u\_{i+1}^{n+1} - 2u\_{i}^{n+1} + u\_{i-1}^{n+1}}{\Delta x^{2}} + (1 - \theta) \frac{u\_{i+1}^{n} - 2u\_{i}^{n} + u\_{i-1}^{n}}{\Delta x^{2}} \right) \\ &+ \theta f\_{i}^{n+1} + (1 - \theta) f\_{i}^{n} .\end{split}$$

This scheme also leads to a matrix system with entries

$$A\_{i,i-1} = -F\theta, \quad A\_{i,i} = 1 + 2F\theta, \quad A\_{i,i+1} = -F\theta,$$

while right-hand side entry bi is

$$b\_i = u\_i^n + F(1 - \theta) \frac{u\_{i+1}^n - 2u\_i^n + u\_{i-1}^n}{\Delta x^2} + \Delta t \theta f\_i^{n+1} + \Delta t (1 - \theta) f\_i^n \dots$$

The corresponding entries for the boundary points are as in the Backward Euler and Crank-Nicolson schemes listed earlier.

Note that convergence rate testing with implementations of the theta rule must adjust the error expression according to which of the underlying schemes is actually being run. That is, if - D <sup>0</sup> (i.e., Forward Euler) or - D <sup>1</sup> (i.e., Backward Euler), there should be first order convergence, whereas with - D 0:5 (i.e., Crank-Nicolson), one should get second order convergence (as outlined in previous sections).

#### **3.2.5 Experiments**

We can repeat the experiments from Sect. 3.1.5 to see if the Backward Euler or Crank-Nicolson schemes have problems with sawtooth-like noise when starting with a discontinuous initial condition. We can also verify that we can have F > <sup>1</sup> 2 , which allows larger time steps than in the Forward Euler method.

The Backward Euler scheme always produces smooth solutions for any F . Figure 3.5 shows one example. Note that the mathematical discontinuity at <sup>t</sup> D <sup>0</sup> leads to a linear variation on a mesh, but the approximation to a jump becomes better as Nx increases. In our simulation, we specify t and F , and set Nx to L=p˛t=F. Since Nx <sup>p</sup><sup>F</sup> , the discontinuity looks sharper in the Crank-Nicolson simulations with larger F .

The Crank-Nicolson method produces smooth solutions for small <sup>F</sup> , <sup>F</sup> <sup>1</sup> 2 , but small noise gets more and more evident as F increases. Figures 3.6 and 3.7 demonstrate the effect for <sup>F</sup> D <sup>3</sup> and <sup>F</sup> D <sup>10</sup>, respectively. Section 3.3 explains why such noise occur.

#### **3.2.6 The Laplace and Poisson Equation**

The Laplace equation, r2u D <sup>0</sup>, and the Poisson equation, r2u D <sup>f</sup> , occur in numerous applications throughout science and engineering. In 1D these equations read <sup>u</sup>00.x/ D <sup>0</sup> and u00.x/ D f .x/, respectively. We can solve 1D variants of the

**Fig. 3.5** Backward Euler scheme for <sup>F</sup> D 0:5

Laplace equations with the listed software, because we can interpret uxx <sup>D</sup> <sup>0</sup> as the limiting solution of ut <sup>D</sup> ˛uxx when <sup>u</sup> reaches a steady state limit where ut ! <sup>0</sup>. Similarly, Poisson's equation uxx <sup>D</sup> <sup>f</sup> arises from solving ut <sup>D</sup> uxx <sup>C</sup> <sup>f</sup> and letting <sup>t</sup> ! 1 so ut ! <sup>0</sup>.

Technically in a program, we can simulate <sup>t</sup> ! 1 by just taking one large time step: t ! 1. In the limit, the Backward Euler scheme gives

$$-\frac{u\_{i+1}^{n+1} - 2u\_i^{n+1} + u\_{i-1}^{n+1}}{\Delta x^2} = f\_i^{n+1},$$

which is nothing but the discretization <sup>Œ</sup>DxDxu <sup>D</sup> f <sup>n</sup>C<sup>1</sup> <sup>i</sup> <sup>D</sup> <sup>0</sup> of uxx <sup>D</sup> <sup>f</sup> .

The result above means that the Backward Euler scheme can solve the limit equation directly and hence produce a solution of the 1D Laplace equation. With the Forward Euler scheme we must do the time stepping since t > x2=˛ is illegal and leads to instability. We may interpret this time stepping as solving the equation system from uxx <sup>D</sup> <sup>f</sup> by iterating on a pseudo time variable.

#### **3.3 Analysis of Schemes for the Diffusion Equation**

The numerical experiments in Sect. 3.1.5 and 3.2.5 reveal that there are some numerical problems with the Forward Euler and Crank-Nicolson schemes: sawtoothlike noise is sometimes present in solutions that are, from a mathematical point of view, expected to be smooth. This section presents a mathematical analysis that explains the observed behavior and arrives at criteria for obtaining numerical solutions that reproduce the qualitative properties of the exact solutions. In short, we shall explain what is observed in Fig. 3.1–3.7.

#### **3.3.1 Properties of the Solution**

A particular characteristic of diffusive processes, governed by an equation like

$$
u\_l = \alpha \boldsymbol{u}\_{xx},\tag{3.42}$$

is that the initial shape u.x; 0/ D I.x/ spreads out in space with time, along with a decaying amplitude. Three different examples will illustrate the spreading of u in space and the decay in time.

**Fig. 3.7** Crank-Nicolson scheme for <sup>F</sup> D <sup>10</sup>

**Similarity solution** The diffusion equation (3.42) admits solutions that depend on D .x c/=p4˛t for a given value of <sup>c</sup>. One particular solution is

$$u(\mathbf{x}, t) = a \operatorname{erf}(\eta) + b,\tag{3.43}$$

where

$$\text{erf}(\eta) = \frac{2}{\sqrt{\pi}} \int\_0^{\eta} e^{-\xi^2} d\xi,\tag{3.44}$$

is the *error function*, and a and b are arbitrary constants. The error function lies in .1; 1/, is odd around D <sup>0</sup>, and goes relatively quickly to ˙1:

$$\begin{aligned} \lim\_{\eta \to -\infty} \text{erf}(\eta) &= -1, \\ \lim\_{\eta \to \infty} \text{erf}(\eta) &= 1, \\ \text{erf}(\eta) &= -\text{erf}(-\eta), \\ \text{erf}(0) &= 0, \\ \text{erf}(2) &= 0.99532227, \\ \text{erf}(3) &= 0.99997791 \end{aligned}$$

As <sup>t</sup> ! <sup>0</sup>, the error function approaches a step function centered at <sup>x</sup> D <sup>c</sup>. For a diffusion problem posed on the unit interval Œ0; 1, we may choose the step at <sup>x</sup> D 1=2 (meaning <sup>c</sup> D 1=2), <sup>a</sup> D 1=2, <sup>b</sup> D 1=2. Then

$$u(x,t) = \frac{1}{2}\left(1 - \text{erf}\left(\frac{x - \frac{1}{2}}{\sqrt{4\alpha t}}\right)\right) = \frac{1}{2}\text{erfc}\left(\frac{x - \frac{1}{2}}{\sqrt{4\alpha t}}\right),\tag{3.45}$$

where we have introduced the *complementary error function* erfc./ D <sup>1</sup> erf./. The solution (3.45) implies the boundary conditions

$$u(0,t) = \frac{1}{2} \left( 1 - \text{erf} \left( \frac{-1/2}{\sqrt{4\alpha t}} \right) \right),\tag{3.46}$$

$$u(1,t) = \frac{1}{2} \left( 1 - \text{erf} \left( \frac{1/2}{\sqrt{4\alpha t}} \right) \right) \,. \tag{3.47}$$

For small enough <sup>t</sup>, u.0; t/ <sup>1</sup> and u.1; t/ <sup>0</sup>, but as <sup>t</sup> ! 1, u.x; t/ ! 1=2 on Œ0; 1.

**Solution for a Gaussian pulse** The standard diffusion equation ut <sup>D</sup> ˛uxx admits a Gaussian function as solution:

$$u(x,t) = \frac{1}{\sqrt{4\pi\alpha t}} \exp\left(-\frac{(x-c)^2}{4\alpha t}\right). \tag{3.48}$$

At <sup>t</sup> D <sup>0</sup> this is a Dirac delta function, so for computational purposes one must start to view the solution at some time <sup>t</sup> <sup>D</sup> <sup>t</sup> > 0. Replacing <sup>t</sup> by <sup>t</sup> <sup>C</sup> <sup>t</sup> in (3.48) makes it easy to operate with a (new) <sup>t</sup> that starts at <sup>t</sup> D <sup>0</sup> with an initial condition with a finite width. The important feature of (3.48) is that the standard deviation of a sharp initial Gaussian pulse increases in time according to D <sup>p</sup>2˛t, making the pulse diffuse and flatten out.

**Solution for a sine component** Also, (3.42) admits a solution of the form

$$u(\mathbf{x},t) = \mathcal{Q}e^{-at}\sin(k\mathbf{x})\,. \tag{3.49}$$

The parameters Q and k can be freely chosen, while inserting (3.49) in (3.42) gives the constraint

$$a = -\alpha k^2 \, .$$

A very important feature is that the initial shape I.x/ D <sup>Q</sup> sin .kx/ undergoes a damping exp .˛k2t/, meaning that rapid oscillations in space, corresponding to large k, are very much faster dampened than slow oscillations in space, corresponding to small k. This feature leads to a smoothing of the initial condition with time. (In fact, one can use a few steps of the diffusion equation as a method for removing noise in signal processing.) To judge how good a numerical method is, we may look at its ability to smoothen or dampen the solution in the same way as the PDE does.

**Fig. 3.8** Evolution of the solution of a diffusion problem: initial condition (*upper left*), 1/100 reduction of the small waves (*upper right*), 1/10 reduction of the long wave (*lower left*), and 1/100 reduction of the long wave (*lower right*)

The following example illustrates the damping properties of (3.49). We consider the specific problem

$$\begin{aligned} u\_t &= u\_{xx}, & x \in (0, 1), & t \in (0, T], \\ u(0, t) &= u(1, t) = 0, & t \in (0, T], \\ u(\mathbf{x}, 0) &= \sin(\pi \mathbf{x}) + 0.1 \sin(100\pi \mathbf{x}) \,. \end{aligned}$$

The initial condition has been chosen such that adding two solutions like (3.49) constructs an analytical solution to the problem:

$$u(\mathbf{x},t) = e^{-\pi^2 t} \sin(\pi \mathbf{x}) + 0.1e^{-\pi^2 10^4 t} \sin(100\pi \mathbf{x})\,. \tag{3.50}$$

Figure 3.8 illustrates the rapid damping of rapid oscillations sin.100x/ and the very much slower damping of the slowly varying sin.x/ term. After about <sup>t</sup> D 0:5 <sup>10</sup><sup>4</sup> the rapid oscillations do not have a visible amplitude, while we have to wait until <sup>t</sup> 0:5 before the amplitude of the long wave sin.x/ becomes very small.

#### **3.3.2 Analysis of Discrete Equations**

A counterpart to (3.49) is the complex representation of the same function:

$$u(x,t) = \mathcal{Q}e^{-at}e^{ikx},$$

where <sup>i</sup> <sup>D</sup> p<sup>1</sup> is the imaginary unit. We can add such functions, often referred to as wave components, to make a Fourier representation of a general solution of the diffusion equation:

$$u(x,t) \approx \sum\_{k \in K} b\_k e^{-\alpha k^2 t} e^{ik \cdot x},\tag{3.51}$$

where K is a set of an infinite number of k values needed to construct the solution. In practice, however, the series is truncated and K is a finite set of k values needed to build a good approximate solution. Note that (3.50) is a special case of (3.51) where <sup>K</sup> D f; 100g, b <sup>D</sup> <sup>1</sup>, and b100 <sup>D</sup> 0:1.

The amplitudes bk of the individual Fourier waves must be determined from the initial condition. At <sup>t</sup> D <sup>0</sup> we have <sup>u</sup> <sup>P</sup> <sup>k</sup> bk exp .ikx/ and find K and bk such that

$$I(\mathbf{x}) \approx \sum\_{k \in K} b\_k e^{ik\mathbf{x}} \,. \tag{3.52}$$

(The relevant formulas for bk come from Fourier analysis, or equivalently, a leastsquares method for approximating I.x/ in a function space with basis exp .ikx/.)

Much insight about the behavior of numerical methods can be obtained by investigating how a wave component exp .˛k2t/ exp .ikx/ is treated by the numerical scheme. It appears that such wave components are also solutions of the schemes, but the damping factor exp .˛k2t/ varies among the schemes. To ease the forthcoming algebra, we write the damping factor as An. The exact amplification factor corresponding to <sup>A</sup> is <sup>A</sup><sup>e</sup> <sup>D</sup> exp .˛k2t/.

#### **3.3.3 Analysis of the Finite Difference Schemes**

We have seen that a general solution of the diffusion equation can be built as a linear combination of basic components

$$e^{-ak^2t}e^{ikx} \dots$$

A fundamental question is whether such components are also solutions of the finite difference schemes. This is indeed the case, but the amplitude exp .˛k2t/ might be modified (which also happens when solving the ODE counterpart <sup>u</sup><sup>0</sup> D ˛u). We therefore look for numerical solutions of the form

$$u\_q^n = A^n e^{ikq\Delta x} = A^n e^{ikx},\tag{3.53}$$

where the amplification factor A must be determined by inserting the component into an actual scheme. Note that An means A raised to the power of n, n being the index in the time mesh, while the superscript n in u<sup>n</sup> <sup>q</sup> just denotes u at time tn.

**Stability** The exact amplification factor is <sup>A</sup><sup>e</sup> <sup>D</sup> exp .˛2k2t/. We should therefore require jAj < 1 to have a decaying numerical solution as well. If <sup>1</sup> A<0, An will change sign from time level to time level, and we get stable, non-physical oscillations in the numerical solutions that are not present in the exact solution.

**Accuracy** To determine how accurately a finite difference scheme treats one wave component (3.53), we see that the basic deviation from the exact solution is reflected in how well An approximates An e, or how well A approximates Ae. We can plot A<sup>e</sup> and the various expressions for A, and we can make Taylor expansions of A=Ae to see the error more analytically.

**Truncation error** As an alternative to examining the accuracy of the damping of a wave component, we can perform a general truncation error analysis as explained in Appendix B. Such results are more general, but less detailed than what we get from the wave component analysis. The truncation error can almost always be computed and represents the error in the numerical model when the exact solution is substituted into the equations. In particular, the truncation error analysis tells the order of the scheme, which is of fundamental importance when verifying codes based on empirical estimation of convergence rates.

#### **3.3.4 Analysis of the Forward Euler Scheme**

The Forward Euler finite difference scheme for ut <sup>D</sup> ˛uxx can be written as

$$[D\_\iota^+u = \alpha D\_\times D\_\times u]\_q^n \,.$$

Inserting a wave component (3.53) in the scheme demands calculating the terms

$$e^{ikq\Delta x}[D\_l^+A]^n = e^{ikq\Delta x}A^n\frac{A-1}{\Delta t},$$

and

$$A^n D\_x D\_x [e^{ik\cdot x}]\_q = A^n \left( -e^{ik \, q \Delta x} \frac{4}{\Delta x^2} \sin^2 \left( \frac{k \Delta x}{2} \right) \right) \dots$$

Inserting these terms in the discrete equation and dividing by Aneikqx leads to

$$\frac{A-1}{\Delta t} = -\alpha \frac{4}{\Delta x^2} \sin^2 \left(\frac{k \Delta x}{2}\right).$$

and consequently

$$A = 1 - 4F\sin^2 p\tag{3.54}$$

where

$$F = \frac{\alpha \Delta t}{\Delta x^2} \tag{3.55}$$

is the *numerical Fourier number*, and <sup>p</sup> D kx=2. The complete numerical solution is then

$$
\mu\_q^n = \left(1 - 4F\sin^2 p\right)^n e^{ikq\Delta x}.\tag{3.56}
$$

**Stability** We easily see that <sup>A</sup> <sup>1</sup>. However, the <sup>A</sup> can be less than 1, which will lead to growth of a numerical wave component. The criterion <sup>A</sup> <sup>1</sup> implies

$$4F\sin^2(p/2) \le 2\dots$$

The worst case is when sin2.p=2/ D <sup>1</sup>, so a sufficient criterion for stability is

$$F \le \frac{1}{2},\tag{3.57}$$

or expressed as a condition on t:

$$
\Delta t \le \frac{\Delta x^2}{2\alpha}.\tag{3.58}
$$

Note that halving the spatial mesh size, x ! <sup>1</sup> <sup>2</sup>x, requires t to be reduced by a factor of 1=4. The method hence becomes very expensive for fine spatial meshes.

**Accuracy** Since <sup>A</sup> is expressed in terms of <sup>F</sup> and the parameter we now call <sup>p</sup> D kx=2, we should also express <sup>A</sup><sup>e</sup> by <sup>F</sup> and <sup>p</sup>. The exponent in <sup>A</sup><sup>e</sup> is ˛k2t, which equals F k2x2 D F 4p<sup>2</sup>. Consequently,

$$A\_{\mathfrak{c}} = \exp\left(-\alpha k^2 \Delta t\right) = \exp\left(-4Fp^2\right).$$

All our A expressions as well as Ae are now functions of the two dimensionless parameters F and p.

Computing the Taylor series expansion of A=Ae in terms of F can easily be done with aid of sympy:

```
def A_exact(F, p):
    return exp(-4*F*p**2)
def A_FE(F, p):
    return 1 - 4*F*sin(p)**2
from sympy import *
F, p = symbols('F p')
A_err_FE = A_FE(F, p)/A_exact(F, p)
print A_err_FE.series(F, 0, 6)
```
The result is

$$\frac{A}{A\_{\mathbf{c}}} = 1 - 4F\sin^2 p + 2F p^2 - 16F^2 p^2 \sin^2 p + 8F^2 p^4 + \dotsb$$

Recalling that <sup>F</sup> D ˛t=x<sup>2</sup>, <sup>p</sup> <sup>D</sup> kx=2, and that sin<sup>2</sup> <sup>p</sup> <sup>1</sup>, we realize that the dominating terms in A=Ae are at most

$$1 - 4\alpha \frac{\Delta t}{\Delta x^2} + \alpha \Delta t - 4\alpha^2 \Delta t^2 + \alpha^2 \Delta t^2 \Delta x^2 + \dotsb \dotsb$$

**Truncation error** We follow the theory explained in Appendix B. The recipe is to set up the scheme in operator notation and use formulas from Appendix B.2.4 to derive an expression for the residual. The details are documented in Appendix B.6.1. We end up with a truncation error

$$R\_i^{\boldsymbol{n}} = \mathcal{O}(\Delta t) + \mathcal{O}(\Delta x^2) \, .$$

Although this is not the true error <sup>u</sup>e.xi ; tn/ <sup>u</sup><sup>n</sup> <sup>i</sup> , it indicates that the true error is of the form

$$E = C\_l \Delta t + C\_x \Delta x^2$$

for two unknown constants Ct and Cx.

#### **3.3.5 Analysis of the Backward Euler Scheme**

Discretizing ut <sup>D</sup> ˛uxx by a Backward Euler scheme,

$$[D\_t^-u = \alpha D\_x D\_x u]\_q^n,$$

and inserting a wave component (3.53), leads to calculations similar to those arising from the Forward Euler scheme, but since

$$e^{ikq\Delta x}[D\_l^-A]^n = A^n e^{ikq\Delta x} \frac{1 - A^{-1}}{\Delta t},$$

we get

$$\frac{1 - A^{-1}}{\Delta t} = -\alpha \frac{4}{\Delta x^2} \sin^2 \left(\frac{k \Delta x}{2}\right),$$

and then

$$A = \left(1 + 4F\sin^2 p\right)^{-1}.\tag{3.59}$$

The complete numerical solution can be written

$$
\mu\_q^n = \left(1 + 4F\sin^2 p\right)^{-n} e^{ikq\Delta x}.\tag{3.60}
$$

**Stability** We see from (3.59) that 0<A<1, which means that all numerical wave components are stable and non-oscillatory for any t > 0.

**Truncation error** The derivation of the truncation error for the Backward Euler scheme is almost identical to that for the Forward Euler scheme. We end up with

$$R^n\_i = \mathcal{O}(\Delta t) + \mathcal{O}(\Delta x^2) \,.$$

#### **3.3.6 Analysis of the Crank-Nicolson Scheme**

The Crank-Nicolson scheme can be written as

$$[D\_t u = \alpha D\_x D\_x \overline{u}^\alpha]\_q^{n + \frac{1}{2}},$$

or

$$[D\_t u]\_q^{n + \frac{1}{2}} = \frac{1}{2} \alpha \left( [D\_x D\_x u]\_q^n + [D\_x D\_x u]\_q^{n+1} \right) \dots$$

Inserting (3.53) in the time derivative approximation leads to

$$[D\_I A^n e^{ikq \Delta x}]^{n + \frac{1}{2}} = A^{n + \frac{1}{2}} e^{ikq \Delta x} \frac{A^{\frac{1}{2}} - A^{-\frac{1}{2}}}{\Delta t} = A^n e^{ikq \Delta x} \frac{A - 1}{\Delta t} \dots$$

Inserting (3.53) in the other terms and dividing by Aneikqx gives the relation

$$\frac{A-1}{\Delta t} = -\frac{1}{2}\alpha \frac{4}{\Delta x^2} \sin^2\left(\frac{k\Delta x}{2}\right)(1+A),$$

and after some more algebra,

$$A = \frac{1 - 2F\sin^2 p}{1 + 2F\sin^2 p}.\tag{3.61}$$

The exact numerical solution is hence

$$u\_q'' = \left(\frac{1 - 2F\sin^2 p}{1 + 2F\sin^2 p}\right)'' e^{ikq\Delta x}.\tag{3.62}$$

**Stability** The criteria A > <sup>1</sup> and A<1 are fulfilled for any t > 0. Therefore, the solution cannot grow, but it will oscillate if <sup>1</sup> 2F sin<sup>p</sup> < 0. To avoid such non-physical oscillations, we must demand <sup>F</sup> <sup>1</sup> 2 .

**Truncation error** The truncation error is derived in Appendix B.6.1:

$$R\_i^{n+\frac{1}{2}} = \mathcal{O}(\Delta x^2) + \mathcal{O}(\Delta t^2) \dots$$

#### **3.3.7 Analysis of the Leapfrog Scheme**

An attractive feature of the Forward Euler scheme is the explicit time stepping and no need for solving linear systems. However, the accuracy in time is only *O*.t/. We can get an explicit *second-order* scheme in time by using the Leapfrog method:

$$[D\_{2l}u = aD\_x D x u + f]\_q^n \, .$$

Written out,

$$u\_q^{n+1} = u\_q^{n-1} + \frac{2\alpha \Delta t}{\Delta x^2} \left( u\_{q+1}^n - 2u\_q^n + u\_{q-1}^n \right) + f(\mathbf{x}\_q, t\_n) \dots$$

**Fig. 3.9** Amplification factors for large time steps

We need some formula for the first step, u<sup>1</sup> <sup>q</sup>, but for that we can use a Forward Euler step.

Unfortunately, the Leapfrog scheme is always unstable for the diffusion equation. To see this, we insert a wave component Aneikx and get

$$\frac{A - A^{-1}}{\Delta t} = -\alpha \frac{4}{\Delta x^2} \sin^2 p,$$

or

$$A^2 + 4F\sin^2 p \, A - 1 = 0,$$

which has roots

$$A = -2F\sin^2 p \pm \sqrt{4F^2 \sin^4 p + 1} \dots$$

Both roots have jAj > 1 so the amplitude always grows, which is not in accordance with the physics of the problem. However, for a PDE with a first-order derivative in space, instead of a second-order one, the Leapfrog scheme performs very well. Details are provided in Sect. 4.1.3.

#### **3.3.8 Summary of Accuracy of Amplification Factors**

We can plot the various amplification factors against <sup>p</sup> D kx=2 for different choices of the F parameter. Figures 3.9, 3.10, and 3.11 show how long and small waves are damped by the various schemes compared to the exact damping. As long as all schemes are stable, the amplification factor is positive, except for Crank-Nicolson when F > 0:5.

The effect of negative amplification factors is that An changes sign from one time level to the next, thereby giving rise to oscillations in time in an animation of the solution. We see from Fig. 3.9 that for <sup>F</sup> D <sup>20</sup>, waves with <sup>p</sup> =4 undergo a damping close to 1, which means that the amplitude does not decay and that the wave component jumps up and down (flips amplitude) in time. For <sup>F</sup> D <sup>2</sup> we have a damping of a factor of 0.5 from one time level to the next, which is very much smaller than the exact damping. Short waves will therefore fail to be effectively

**Fig. 3.10** Amplification factors for time steps around the Forward Euler stability limit

**Fig. 3.11** Amplification factors for small time steps

dampened. These waves will manifest themselves as high frequency oscillatory noise in the solution.

A value <sup>p</sup> D =4 corresponds to four mesh points per wave length of <sup>e</sup>ikx, while <sup>p</sup> D =2 implies only two points per wave length, which is the smallest number of points we can have to represent the wave on the mesh.

To demonstrate the oscillatory behavior of the Crank-Nicolson scheme, we choose an initial condition that leads to short waves with significant amplitude. A discontinuous I.x/ will in particular serve this purpose: Figures 3.6 and 3.7 correspond to <sup>F</sup> D <sup>3</sup> and <sup>F</sup> D <sup>10</sup>, respectively, and we see how short waves pollute the overall solution.

#### **3.3.9 Analysis of the 2D Diffusion Equation**

Diffusion in several dimensions is treated later, but it is appropriate to include the analysis here. We first consider the 2D diffusion equation

$$u\_l = \alpha (u\_{\ge \chi} + u\_{\ge \chi}),$$

which has Fourier component solutions of the form

$$u(\boldsymbol{x}, \boldsymbol{y}, t) = A e^{-\alpha k^2 t} e^{i(k\_{\boldsymbol{x}} \boldsymbol{x} + k\_{\boldsymbol{y}} \boldsymbol{y})},$$

and the schemes have discrete versions of this Fourier component:

$$u\_{q,r}^n = A\xi^n e^{i\left(k\_\Lambda q \Delta x + k\_{\bar{\nu}} r \Delta y\right)}.$$

**The Forward Euler scheme** For the Forward Euler discretization,

$$[D\_\iota^+\iota = \alpha(D\_x D\_x \iota + D\_\jmath D\_\jmath \iota)]\_{q,r}^n,$$

we get

$$\frac{\xi - 1}{\Delta t} = -\alpha \frac{4}{\Delta x^2} \sin^2 \left( \frac{k\_x \Delta x}{2} \right) - \alpha \frac{4}{\Delta y^2} \sin^2 \left( \frac{k\_y \Delta y}{2} \right) \dots$$

Introducing

$$p\_{\chi} = \frac{k\_{\chi} \Delta x}{2}, \quad p\_{\mathcal{Y}} = \frac{k\_{\mathcal{Y}} \Delta \mathcal{Y}}{2},$$

we can write the equation for more compactly as

$$\frac{\xi - 1}{\Delta t} = -\alpha \frac{4}{\Delta x^2} \sin^2 p\_\times - \alpha \frac{4}{\Delta y^2} \sin^2 p\_\times.$$

and solve for :

$$\xi = 1 - 4F\_{\chi} \sin^2 p\_{\chi} - 4F\_{\chi} \sin^2 p\_{\chi} \,. \tag{3.63}$$

The complete numerical solution for a wave component is

$$u\_{q,r}^{n} = A(1 - 4F\_{\chi}\sin^{2}p\_{\chi} - 4F\_{\chi}\sin^{2}p\_{\chi})^{n}e^{i(k\_{\lambda}q\Delta x + k\_{\overline{\chi}}r\Delta y)}.\tag{3.64}$$

For stability we demand <sup>1</sup> <sup>1</sup>, and <sup>1</sup> is the critical limit, since clearly <sup>1</sup>, and the worst case happens when the sines are at their maximum. The stability criterion becomes

$$F\_{\chi} + F\_{\chi} \le \frac{1}{2} \,. \tag{3.65}$$

For the special, yet common, case x D y D <sup>h</sup>, the stability criterion can be written as

$$
\Delta t \le \frac{h^2}{2d\alpha},
$$

where <sup>d</sup> is the number of space dimensions: <sup>d</sup> D 1; 2; 3.

**The Backward Euler scheme** The Backward Euler method,

$$[D\_t^-u = \alpha(D\_x D\_x u + D\_y D\_y u)]\_{q,r}^n,$$

results in

$$1 - \xi^{-1} = -4F\_{\underline{x}} \sin^2 p\_{\underline{x}} - 4F\_{\underline{y}} \sin^2 p\_{\underline{y}},$$

and

$$\xi = (1 + 4F\_{\chi}\sin^2 p\_{\chi} + 4F\_{\chi}\sin^2 p\_{\chi})^{-1},$$

which is always in .0; 1. The solution for a wave component becomes

$$u\_{q,r}^{n} = A(1 + 4F\_{\chi}\sin^{2}{p\_{\chi}} + 4F\_{\mathcal{Y}}\sin^{2}{p\_{\mathcal{Y}}})^{-n}e^{i(k\_{\mathcal{U}}q\Delta x + k\_{\mathcal{Y}}r\Delta y)}.\tag{3.66}$$

**The Crank-Nicolson scheme** With a Crank-Nicolson discretization,

$$\left[ [D\_l u]\_{q,r}^{n+\frac{1}{2}} \right] = \frac{1}{2} [a(D\_x D\_x u + D\_y D\_y u)]\_{q,r}^{n+1} + \frac{1}{2} [a(D\_x D\_x u + D\_y D\_y u)]\_{q,r}^{n},$$

we have, after some algebra,

$$\xi = \frac{1 - 2(F\_{\times}\sin^{2}p\_{\times} + F\_{\times}\sin^{2}p\_{\circ})}{1 + 2(F\_{\times}\sin^{2}p\_{\times} + F\_{\times}\sin^{2}p\_{\circ})} \cdot \xi$$

The fraction on the right-hand side is always less than 1, so stability in the sense of non-growing wave components is guaranteed for all physical and numerical parameters. However, the fraction can become negative and result in non-physical oscillations. This phenomenon happens when

$$F\_{\text{x}}\sin^{2}{p\_{\text{x}}} + F\_{\text{x}}\sin^{2}{p\_{\text{y}}} > \frac{1}{2}\ .$$

A criterion against non-physical oscillations is therefore

$$F\_x + F\_y \le \frac{1}{2},$$

which is the same limit as the stability criterion for the Forward Euler scheme.

The exact discrete solution is

$$u\_{q,r}^{n} = A \left( \frac{1 - 2(F\_{\text{x}} \sin^{2} p\_{\text{x}} + F\_{\text{x}} \sin^{2} p\_{\text{y}})}{1 + 2(F\_{\text{x}} \sin^{2} p\_{\text{x}} + F\_{\text{x}} \sin^{2} p\_{\text{y}})} \right)^{n} e^{i(k\_{\text{x}} q \Delta x + k\_{\text{y}} r \Delta y)}. \tag{3.67}$$

#### **3.3.10 Explanation of Numerical Artifacts**

The behavior of the solution generated by Forward Euler discretization in time (and centered differences in space) is summarized at the end of Sect. 3.1.5. Can we, from the analysis above, explain the behavior?

We may start by looking at Fig. 3.3 where <sup>F</sup> D 0:51. The figure shows that the solution is unstable and grows in time. The stability limit for such growth is <sup>F</sup> D 0:5 and since the <sup>F</sup> in this simulation is slightly larger, growth is unavoidable.

Figure 3.1 has unexpected features: we would expect the solution of the diffusion equation to be smooth, but the graphs in Fig. 3.1 contain non-smooth noise. Turning to Fig. 3.4, which has a quite similar initial condition, we see that the curves are indeed smooth. The problem with the results in Fig. 3.1 is that the initial condition is discontinuous. To represent it, we need a significant amplitude on the shortest waves in the mesh. However, for <sup>F</sup> D 0:5, the shortest wave (<sup>p</sup> D =2) gives the amplitude in the numerical solution as .14F /n, which oscillates between negative and positive values at subsequent time levels for F > <sup>1</sup> <sup>4</sup> . Since the shortest waves have visible amplitudes in the solution profile, the oscillations becomes visible. The smooth initial condition in Fig. 3.4, on the other hand, leads to very small amplitudes of the shortest waves. That these waves then oscillate in a non-physical way for <sup>F</sup> D 0:5 is not a visible effect. The oscillations in time in the amplitude .1 4F /n disappear for <sup>F</sup> <sup>1</sup> <sup>4</sup> , and that is why also the discontinuous initial condition always leads to smooth solutions in Fig. 3.2, where <sup>F</sup> D <sup>1</sup> 4 .

Turning the attention to the Backward Euler scheme and the experiments in Fig. 3.5, we see that even the discontinuous initial condition gives smooth solutions for <sup>F</sup> D 0:5 (and in fact all other <sup>F</sup> values). From the exact expression of the numerical amplitude, .1 C 4F sin<sup>2</sup> p/<sup>1</sup>, we realize that this factor can never flip between positive and negative values, and no instabilities can occur. The conclusion is that the Backward Euler scheme always produces smooth solutions. Also, the Backward Euler scheme guarantees that the solution cannot grow in time (unless we add a source term to the PDE, but that is meant to represent a physically relevant growth).

Finally, we have some small, strange artifacts when simulating the development of the initial plug profile with the Crank-Nicolson scheme, see Fig. 3.7, where <sup>F</sup> D <sup>3</sup>. The Crank-Nicolson scheme cannot give growing amplitudes, but it may give oscillating amplitudes in time. The critical factor is <sup>1</sup> 2F sin<sup>2</sup> <sup>p</sup>, which for the shortest waves (<sup>p</sup> D =2) indicates a stability limit <sup>F</sup> D 0:5. With the discontinuous initial condition, we have enough amplitude on the shortest waves so their wrong behavior is visible, and this is what we see as small instabilities in Fig. 3.7. The only remedy is to lower the F value.

#### **3.4 Exercises**

#### **Exercise 3.1: Explore symmetry in a 1D problem**

This exercise simulates the exact solution (3.48). Suppose for simplicity that <sup>c</sup> D <sup>0</sup>.


value problem in this case. Simulate the diffusion problem in Œ0; L and compare with the solution in a).

Filename: diffu\_symmetric\_gaussian.

#### **Exercise 3.2: Investigate approximation errors from a** ux <sup>D</sup> <sup>0</sup> **boundary condition**

We consider the problem solved in Exercise 3.1 part b). The boundary condition ux.0; t/ D <sup>0</sup> can be implemented in two ways: 1) by a standard symmetric finite difference ŒD2xu<sup>n</sup> <sup>i</sup> <sup>D</sup> <sup>0</sup>, or 2) by a one-sided difference ŒDC<sup>u</sup> <sup>D</sup> 0<sup>n</sup> <sup>i</sup> <sup>D</sup> <sup>0</sup>. Investigate the effect of these two conditions on the convergence rate in space.

*Hint* If you use a Forward Euler scheme, choose a discretization parameter <sup>h</sup> D t D x<sup>2</sup> and assume the error goes like <sup>E</sup> hr . The error in the scheme is *<sup>O</sup>*.t; x2/ so one should expect that the estimated r approaches 1. The question is if a one-sided difference approximation to ux.0; t/ D <sup>0</sup> destroys this convergence rate.

Filename: diffu\_onesided\_fd.

#### **Exercise 3.3: Experiment with open boundary conditions in 1D**

We address diffusion of a Gaussian function as in Exercise 3.1, in the domain Œ0; L, but now we shall explore different types of boundary conditions on <sup>x</sup> D <sup>L</sup>. In reallife problems we do not know the exact solution on <sup>x</sup> D <sup>L</sup> and must use something simpler.

a) Imagine that we want to solve the problem numerically on Œ0; L, with a symmetry boundary condition ux <sup>D</sup> <sup>0</sup> at <sup>x</sup> <sup>D</sup> <sup>0</sup>, but we do not know the exact solution and cannot of that reason assign a correct Dirichlet condition at <sup>x</sup> D <sup>L</sup>. One idea is to simply set u.L; t/ D <sup>0</sup> since this will be an accurate approximation before the diffused pulse reaches <sup>x</sup> D <sup>L</sup> and even thereafter it might be a satisfactory condition if the exact u has a small value. Let ue be the exact solution and let <sup>u</sup> be the solution of ut <sup>D</sup> ˛uxx with an initial Gaussian pulse and the boundary conditions ux.0; t/ D u.L; t/ D <sup>0</sup>. Derive a diffusion problem for the error <sup>e</sup> <sup>D</sup> <sup>u</sup><sup>e</sup> <sup>u</sup>. Solve this problem numerically using an exact Dirichlet condition at <sup>x</sup> D <sup>L</sup>. Animate the evolution of the error and make a curve plot of the error measure

$$E(t) = \sqrt{\frac{\int\_0^L e^2 dx}{\int\_0^L u dx}}.$$

Is this a suitable error measure for the present problem?

b) Instead of using u.L; t/ D <sup>0</sup> as approximate boundary condition for letting the diffused Gaussian pulse move out of our finite domain, one may try ux.L; t/ D 0 since the solution for large t is quite flat. Argue that this condition gives a completely wrong asymptotic solution as <sup>t</sup> ! <sup>0</sup>. To do this, integrate the diffusion equation from 0 to L, integrate uxx by parts (or use Gauss' divergence theorem in 1D) to arrive at the important property

$$\frac{d}{dt} \int\_{0}^{L} u(x, t) dx = 0,$$

implying that R <sup>L</sup> <sup>0</sup> udx must be constant in time, and therefore

$$\int\_{0}^{L} u(\mathbf{x}, t)d\mathbf{x} = \int\_{0}^{L} I(\mathbf{x})d\mathbf{x} \,\, \underline{\mathbf{x}}$$

The integral of the initial pulse is 1.

c) Another idea for an artificial boundary condition at <sup>x</sup> D <sup>L</sup> is to use a cooling law

$$-\alpha u\_{\times} = q(u - u\_{\mathcal{S}}),\tag{3.68}$$

where q is an unknown heat transfer coefficient and uS is the surrounding temperature in the medium outside of Œ0; L. (Note that arguing that uS is approximately u.L; t/ gives the ux <sup>D</sup> <sup>0</sup> condition from the previous subexercise that is qualitatively wrong for large t.) Develop a diffusion problem for the error in the solution using (3.68) as boundary condition. Assume one can take uS <sup>D</sup> <sup>0</sup> "outside the domain" since <sup>u</sup><sup>e</sup> ! <sup>0</sup> as <sup>x</sup> ! 1. Find a function <sup>q</sup> <sup>D</sup> q.t/ such that the exact solution obeys the condition (3.68). Test some constant values of q and animate how the corresponding error function behaves. Also compute E.t/ curves as defined above.

Filename: diffu\_open\_BC.

#### **Exercise 3.4: Simulate a diffused Gaussian peak in 2D/3D**


Filename: diffu\_symmetric\_gaussian\_2D.

**Exercise 3.5: Examine stability of a diffusion model with a source term** Consider a diffusion equation with a linear u term:

$$u\_l = \alpha u\_{xx} + \beta u\_z$$


*Hint* Insert (3.49) in the PDE problem.

c) Calculate the stability of the Forward Euler scheme. Design numerical experiments to confirm the results.

*Hint* Insert the discrete counterpart to (3.49) in the numerical scheme. Run experiments at the stability limit and slightly above.


*Hint* For analysis of the accuracy, compare the numerical and exact amplification factors, in graphs and/or by Taylor series expansion. Filename: diffu\_stability\_uterm.

#### **3.5 Diffusion in Heterogeneous Media**

Diffusion in heterogeneous media normally implies a non-constant diffusion coefficient ˛ D ˛.x/. A 1D diffusion model with such a variable diffusion coefficient reads



A short form of the diffusion equation with variable coefficients is ut <sup>D</sup> .˛ux/x <sup>C</sup> f .

#### **3.5.1 Discretization**

We can discretize (3.69) by a --rule in time and centered differences in space:

$$\theta\_i \left[ D\_l u \right]\_i^{n + \frac{1}{2}} = \theta [D\_x(\overline{\alpha}^\times D\_x u) + f]\_i^{n+1} + (1 - \theta) [D\_x(\overline{\alpha}^\times D\_x u) + f]\_i^{n+1}$$

Written out, this becomes

$$\begin{split} \frac{u\_{i}^{n+1} - u\_{i}^{n}}{\Delta t} &= \theta \frac{1}{\Delta x^{2}} \left( \alpha\_{i + \frac{1}{2}} (u\_{i+1}^{n+1} - u\_{i}^{n+1}) - \alpha\_{i - \frac{1}{2}} (u\_{i}^{n+1} - u\_{i-1}^{n+1}) \right) \\ &+ (1 - \theta) \frac{1}{\Delta x^{2}} \left( \alpha\_{i + \frac{1}{2}} (u\_{i+1}^{n} - u\_{i}^{n}) - \alpha\_{i - \frac{1}{2}} (u\_{i}^{n} - u\_{i-1}^{n}) \right) \\ &+ \theta f\_{i}^{n+1} + (1 - \theta) f\_{i}^{n}, \end{split}$$

where, e.g., an arithmetic mean can to be used for ˛iC <sup>1</sup> 2 :

$$
\alpha\_{i + \frac{1}{2}} = \frac{1}{2} (\alpha\_i + \alpha\_{i+1}) \dots
$$

#### **3.5.2 Implementation**

Suitable code for solving the discrete equations is very similar to what we created for a constant ˛. Since the Fourier number has no meaning for varying ˛, we introduce a related parameter <sup>D</sup> D t=x<sup>2</sup>.

```
def solver_theta(I, a, L, Nx, D, T, theta=0.5, u_L=1, u_R=0,
                 user_action=None):
   x = linspace(0, L, Nx+1) # mesh points in space
   dx = x[1] - x[0]
   dt = D*dx**2
   Nt = int(round(T/float(dt)))
   t = linspace(0, T, Nt+1) # mesh points in time
   u = zeros(Nx+1) # solution array at t[n+1]
   u_n = zeros(Nx+1) # solution at t[n]
   Dl = 0.5*D*theta
   Dr = 0.5*D*(1-theta)
   # Representation of sparse matrix and right-hand side
   diagonal = zeros(Nx+1)
   lower = zeros(Nx)
   upper = zeros(Nx)
   b = zeros(Nx+1)
   # Precompute sparse matrix (scipy format)
   diagonal[1:-1]=1+ Dl*(a[2:] + 2*a[1:-1] + a[:-2])
   lower[:-1] = -Dl*(a[1:-1] + a[:-2])
   upper[1:] = -Dl*(a[2:] + a[1:-1])
   # Insert boundary conditions
   diagonal[0] = 1
   upper[0] = 0
   diagonal[Nx]=1
   lower[-1] = 0
   A = scipy.sparse.diags(
       diagonals=[diagonal, lower, upper],
       offsets=[0, -1, 1],
       shape=(Nx+1, Nx+1),
       format='csr')
```

```
# Set initial condition
for i in range(0,Nx+1):
    u_n[i] = I(x[i])
if user_action is not None:
    user_action(u_n, x, t, 0)
# Time loop
for n in range(0, Nt):
    b[1:-1] = u_n[1:-1] + Dr*(
        (a[2:] + a[1:-1])*(u_n[2:] - u_n[1:-1]) -
        (a[1:-1] + a[0:-2])*(u_n[1:-1] - u_n[:-2]))
    # Boundary conditions
    b[0] = u_L(t[n+1])
    b[-1] = u_R(t[n+1])
    # Solve
    u[:] = scipy.sparse.linalg.spsolve(A, b)
    if user_action is not None:
        user_action(u, x, t, n+1)
    # Switch variables before next step
    u_n, u = u, u_n
```
The code is found in the file diffu1D\_vc.py.

#### **3.5.3 Stationary Solution**

As <sup>t</sup> ! 1, the solution of the problem (3.69)–(3.72) will approach a stationary limit where @u=@t D <sup>0</sup>. The governing equation is then

$$\frac{d}{dx}\left(\alpha \frac{du}{dx}\right) = 0,\tag{3.73}$$

with boundary conditions u.0/ <sup>D</sup> U0 and u.L/ <sup>D</sup> UL. It is possible to obtain an exact solution of (3.73) for any ˛. Integrating twice and applying the boundary conditions to determine the integration constants gives

$$u(\mathbf{x}) = U\_0 + (U\_L - U\_0) \frac{\int\_0^\chi (\alpha(\xi))^{-1} d\xi}{\int\_0^L (\alpha(\xi))^{-1} d\xi}. \tag{3.74}$$

#### **3.5.4 Piecewise Constant Medium**

Consider a medium built of M layers. The layer boundaries are denoted b0;:::;bM , where b0 <sup>D</sup> <sup>0</sup> and bM <sup>D</sup> <sup>L</sup>. If the layers potentially have different material properties, but these properties are constant within each layer, we can express ˛ as a *piecewise constant function* according to

$$\alpha(\mathbf{x}) = \begin{cases} \alpha\_0, & b\_0 \le \mathbf{x} < b\_1, \\ \vdots \\ \alpha\_i, & b\_i \le \mathbf{x} < b\_{i+1}, \\ \vdots \\ \alpha\_{M-1}, & b\_{M-1} \le \mathbf{x} \le b\_M. \end{cases} \tag{3.75}$$

The exact solution (3.74) in case of such a piecewise constant ˛ function is easy to derive. Assume that <sup>x</sup> is in the <sup>m</sup>-th layer: <sup>x</sup> <sup>2</sup> Œbm; bmC1. In the integral R x <sup>0</sup> .a.//1d we must integrate through the first <sup>m</sup> <sup>1</sup> layers and then add the contribution from the remaining part <sup>x</sup> bm into the <sup>m</sup>-th layer:

$$u(\mathbf{x}) = U\_0 + (U\_L - U\_0) \frac{\sum\_{j=0}^{m-1} (b\_{j+1} - b\_j)/\alpha (b\_j) + (\mathbf{x} - b\_m)/\alpha (b\_m)}{\sum\_{j=0}^{M-1} (b\_{j+1} - b\_j)/\alpha (b\_j)}. \tag{3.76}$$

**Remark** It may sound strange to have a discontinuous ˛ in a differential equation where one is to differentiate, but a discontinuous ˛ is compensated by a discontinuous ux such that ˛ux is continuous and therefore can be differentiated as .˛ux/x.

#### **3.5.5 Implementation of Diffusion in a Piecewise Constant Medium**

Programming with piecewise function definitions quickly becomes cumbersome as the most naive approach is to test for which interval x lies, and then start evaluating a formula like (3.76). In Python, vectorized expressions may help to speed up the computations. The convenience classes PiecewiseConstant and IntegratedPiecewiseConstant in the Heaviside module were made to simplify programming with functions like (3.75) and expressions like (3.76). These utilities not only represent piecewise constant functions, but also *smoothed* versions of them where the discontinuities can be smoothed out in a controlled fashion.

The PiecewiseConstant class is created by sending in the domain as a 2-tuple or 2-list and a data object describing the boundaries b0;:::;bM and the corresponding function values ˛0;:::;˛M<sup>1</sup>. More precisely, data is a nested list, where data[i][0] holds bi and data[i][1] holds the corresponding value ˛i , for <sup>i</sup> <sup>D</sup> 0; : : : ; M <sup>1</sup>. Given bi and ˛i in arrays <sup>b</sup> and <sup>a</sup>, it is easy to fill out the nested list data.

In our application, we want to represent ˛ and 1=˛ as piecewise constant functions, in addition to the u.x/ function which involves the integrals of 1=˛. A class creating the functions we need and a method for evaluating u, can take the form

```
class SerialLayers:
    """
    b: coordinates of boundaries of layers, b[0] is left boundary
    and b[-1] is right boundary of the domain [0,L].
    a: values of the functions in each layer (len(a) = len(b)-1).
    U_0: u(x) value at left boundary x=0=b[0].
    U_L: u(x) value at right boundary x=L=b[0].
    """
```

```
def __init__(self, a, b, U_0, U_L, eps=0):
    self.a, self.b = np.asarray(a), np.asarray(b)
    self.eps = eps # smoothing parameter for smoothed a
    self.U_0, self.U_L = U_0, U_L
    a_data = [[bi, ai] for bi, ai in zip(self.b, self.a)]
    domain = [b[0], b[-1]]
    self.a_func = PiecewiseConstant(domain, a_data, eps)
    # inv_a = 1/a is needed in formulas
    inv_a_data = [[bi, 1./ai] for bi, ai in zip(self.b, self.a)]
    self.inv_a_func = \
         PiecewiseConstant(domain, inv_a_data, eps)
    self.integral_of_inv_a_func = \
         IntegratedPiecewiseConstant(domain, inv_a_data, eps)
    # Denominator in the exact formula is constant
    self.inv_a_0L = self.integral_of_inv_a_func(b[-1])
def __call__(self, x):
    solution = self.U_0 + (self.U_L-self.U_0)*\
               self.integral_of_inv_a_func(x)/self.inv_a_0L
    return solution
```
A visualization method is also convenient to have. Below we plot u.x/ along with ˛.x/ (which works well as long as max ˛.x/ is of the same size as max <sup>u</sup> D max.U0; UL/).

```
class SerialLayers:
    ...
    def plot(self):
        x, y_a = self.a_func.plot()
        x = np.asarray(x); y_a = np.asarray(y_a)
        y_u = self.u_exact(x)
        import matplotlib.pyplot as plt
        plt.figure()
        plt.plot(x, y_u, 'b')
        plt.hold('on') # Matlab style
        plt.plot(x, y_a, 'r')
        ymin = -0.1
        ymax = 1.2*max(y_u.max(), y_a.max())
        plt.axis([x[0], x[-1], ymin, ymax])
        plt.legend(['solution $u$', 'coefficient $a$'], loc='upper left')
        if self.eps > 0:
            plt.title('Smoothing eps: %s' % self.eps)
        plt.savefig('tmp.pdf')
        plt.savefig('tmp.png')
        plt.show()
```
Figure 3.12 shows the case where

```
b = [0, 0.25, 0.5, 1] # material boundaries
a = [0.2, 0.4, 4] # material values
U_0 = 0.5; U_L = 5 # boundary conditions
```
**Fig. 3.12** Solution of the stationary diffusion equation corresponding to a piecewise constant diffusion coefficient

**Fig. 3.13** Solution of the stationary diffusion equation corresponding to a *smoothed* piecewise constant diffusion coefficient

By adding the eps parameter to the constructor of the SerialLayers class, we can experiment with smoothed versions of ˛ and see the (small) impact on u. Figure 3.13 shows the result.

#### **3.5.6 Axi-Symmetric Diffusion**

Suppose we have a diffusion process taking place in a straight tube with radius R. We assume axi-symmetry such that u is just a function of r and t, with r being the radial distance from the center axis of the tube to a point. With such axi-symmetry it is advantageous to introduce *cylindrical coordinates* r, -, and z, where z is in the direction of the tube and .r; - / are polar coordinates in a cross section. Axisymmetry means that all quantities are independent of -. From the relations <sup>x</sup> D cos -, <sup>y</sup> D sin -, and <sup>z</sup> D <sup>z</sup>, between Cartesian and cylindrical coordinates, one can (with some effort) derive the diffusion equation in cylindrical coordinates, which with axi-symmetry takes the form

$$\frac{\partial u}{\partial t} = \frac{1}{r} \frac{\partial}{\partial r} \left( r \alpha(r, z) \frac{\partial u}{\partial r} \right) + \frac{\partial}{\partial z} \left( \alpha(r, z) \frac{\partial u}{\partial z} \right) + f(r, z, t) \dots$$

Let us assume that u does not change along the tube axis so it suffices to compute variations in a cross section. Then @u=@z D <sup>0</sup> and we have a 1D diffusion equation in the radial coordinate r and time t. In particular, we shall address the initialboundary value problem

$$\frac{\partial u}{\partial t} = \frac{1}{r} \frac{\partial}{\partial r} \left( r a(r) \frac{\partial u}{\partial r} \right) + f(t), \qquad r \in (0, R), \ t \in (0, T], \qquad (3.77)$$

$$\frac{\partial u}{\partial r}(0,t) = 0,\tag{3.7}$$

$$u(R,t) = 0,\tag{3.79}$$

$$u(R,0) = u(R,t) \tag{3.10}$$

$$u(r,0) = I(r), \quad \qquad \qquad r \in [0,R]. \tag{3.80}$$

The condition (3.78) is a necessary symmetry condition at <sup>r</sup> D <sup>0</sup>, while (3.79) could be any Dirichlet or Neumann condition (or Robin condition in case of cooling or heating).

The finite difference approximation will need the discretized version of the PDE for <sup>r</sup> D <sup>0</sup> (just as we use the PDE at the boundary when implementing Neumann conditions). However, discretizing the PDE at <sup>r</sup> D <sup>0</sup> poses a problem because of the 1=r factor. We therefore need to work out the PDE for discretization at <sup>r</sup> D <sup>0</sup> with care. Let us, for the case of constant ˛, expand the spatial derivative term to

$$
\alpha \frac{\partial^2 u}{\partial r^2} + \alpha \frac{1}{r} \frac{\partial u}{\partial r} \dots
$$

The last term faces a difficulty at <sup>r</sup> D <sup>0</sup>, since it becomes a 0=0 expression caused by the symmetry condition at <sup>r</sup> D <sup>0</sup>. However, L'Hosptial's rule can be used:

$$\lim\_{r \to 0} \frac{1}{r} \frac{\partial u}{\partial r} = \frac{\partial^2 u}{\partial r^2} \dots$$

The PDE at <sup>r</sup> D <sup>0</sup> therefore becomes

$$\frac{\partial u}{\partial t} = 2\alpha \frac{\partial^2 u}{\partial r^2} + f(t) \,. \tag{3.81}$$

For a variable coefficient ˛.r / the expanded spatial derivative term reads

$$
\alpha(r)\frac{\partial^2 u}{\partial r^2} + \frac{1}{r}(\alpha(r) + r\alpha'(r))\frac{\partial u}{\partial r} \dots
$$

We are interested in this expression for <sup>r</sup> D <sup>0</sup>. A necessary condition for <sup>u</sup> to be axi-symmetric is that all input data, including ˛, must also be axi-symmetric, implying that ˛<sup>0</sup> .0/ D <sup>0</sup> (the second term vanishes anyway because of <sup>r</sup> D <sup>0</sup>). The limit of interest is

$$\lim\_{r \to 0} \frac{1}{r} \alpha(r) \frac{\partial u}{\partial r} = \alpha(0) \frac{\partial^2 u}{\partial r^2} \dots$$

The PDE at <sup>r</sup> D <sup>0</sup> now looks like

$$\frac{\partial u}{\partial t} = 2\alpha(0)\frac{\partial^2 u}{\partial r^2} + f(t),\tag{3.82}$$

so there is no essential difference between the constant coefficient and variable coefficient cases.

The second-order derivative in (3.81) and (3.82) is discretized in the usual way.

$$2\alpha \frac{\partial^2}{\partial r^2} \mu(r\_0, t\_n) \approx [2\alpha D\_r D\_r u]\_0^n = 2\alpha \frac{u\_1^n - 2u\_0^n + u\_{-1}^n}{\Delta r^2}.$$

The fictitious value u<sup>n</sup> <sup>1</sup> can be eliminated using the discrete symmetry condition

$$[D\_{2r}u = 0]\_0^n \quad \Rightarrow \quad u\_{-1}^n = u\_1^n,$$

which then gives the modified approximation to the term with the second-order derivative of <sup>u</sup> in <sup>r</sup> at <sup>r</sup> D <sup>0</sup>:

$$4\alpha \frac{u\_1^n - u\_0^n}{\Delta r^2}.\tag{3.83}$$

The discretization of the term with the second-order derivative in r at any internal mesh point is straightforward:

$$\begin{split} \left[ \frac{1}{r} \frac{\partial}{\partial r} \left( r a \frac{\partial u}{\partial r} \right) \right]\_i^n &\approx \left[ r^{-1} D\_r (r a D\_r u) \right]\_i^n \\ &= \frac{1}{r\_i} \frac{1}{\Delta r^2} \left( r\_{i + \frac{1}{2}} \alpha\_{i + \frac{1}{2}} (u\_{i + 1}^n - u\_i^n) - r\_{i - \frac{1}{2}} \alpha\_{i - \frac{1}{2}} (u\_i^n - u\_{i - 1}^n) \right) . \end{split}$$

To complete the discretization, we need a scheme in time, but that can be done as before and does not interfere with the discretization in space.

#### **3.5.7 Spherically-Symmetric Diffusion**

**Discretization in spherical coordinates** Let us now pose the problem from Sect. 3.5.6 in spherical coordinates, where u only depends on the radial coordinate r and time t. That is, we have spherical symmetry. For simplicity we restrict the diffusion coefficient ˛ to be a constant. The PDE reads

$$\frac{\partial \boldsymbol{u}}{\partial t} = \frac{\alpha}{r^{\gamma}} \frac{\partial}{\partial r} \left( r^{\gamma} \frac{\partial \boldsymbol{u}}{\partial r} \right) + f(t), \tag{3.84}$$

for <sup>r</sup> 2 .0; R/ and <sup>t</sup> 2 .0; T . The parameter is 2 for spherically-symmetric problems and 1 for axi-symmetric problems. The boundary and initial conditions have the same mathematical form as in (3.77)–(3.80).

Since the PDE in spherical coordinates has the same form as the PDE in Sect. 3.5.6, just with the parameter being different, we can use the same discretization approach. At the origin <sup>r</sup> D <sup>0</sup> we get problems with the term

$$\frac{\nu}{r} \frac{\partial u}{\partial t},$$

but L'Hosptial's rule shows that this term equals @2u=@r <sup>2</sup>, and the PDE at <sup>r</sup> D <sup>0</sup> becomes

$$\frac{\partial u}{\partial t} = (\chi + 1)\alpha \frac{\partial^2 u}{\partial r^2} + f(t) \,. \tag{3.85}$$

The associated discrete form is then

$$\left[D\_{l}u = \frac{1}{2}(\gamma + 1)\alpha D\_{r}D\_{r}\overline{u}^{l} + \overline{f}^{l}\right]\_{l}^{n + \frac{1}{2}},\tag{3.86}$$

for a Crank-Nicolson scheme.

**Discretization in Cartesian coordinates** The spherically-symmetric spatial derivative can be transformed to the Cartesian counterpart by introducing

$$v(r,t) = ru(r,t) \; .$$

Inserting <sup>u</sup> D v=r in

$$\frac{1}{r^2}\frac{\partial}{\partial r}\left(\alpha(r)r^2\frac{\partial u}{\partial r}\right),$$

yields

$$r\left(\frac{d\alpha}{dr}\frac{\partial v}{\partial r} + \alpha \frac{\partial^2 v}{\partial r^2}\right) - \frac{d\alpha}{dr}v \dots$$

The two terms in the parenthesis can be combined to

$$r\frac{\partial}{\partial r}\left(\alpha \frac{\partial v}{\partial r}\right)\cdot$$

The PDE for v takes the form

$$\frac{\partial v}{\partial t} = \frac{\partial}{\partial r} \left( a \frac{\partial v}{\partial r} \right) - \frac{1}{r} \frac{d\alpha}{dr} v + rf(r, t), \quad r \in (0, R), \ t \in (0, T]. \tag{3.87}$$

For ˛ constant we immediately realize that we can reuse a solver in Cartesian coordinates to compute v. With variable ˛, a "reaction" term v=r needs to be added to the Cartesian solver. The boundary condition @u=@r D <sup>0</sup> at <sup>r</sup> D <sup>0</sup>, implied by symmetry, forces v.0; t/ D <sup>0</sup>, because

$$\frac{\partial u}{\partial r} = \frac{1}{r^2} \left( r \frac{\partial v}{\partial r} - v \right) = 0, \quad r = 0 \dots$$

#### **3.6 Diffusion in 2D**

We now address diffusion in two space dimensions:

$$\frac{\partial u}{\partial t} = \alpha \left( \frac{\partial^2 u}{\partial \mathbf{x}^2} + \frac{\partial^2 u}{\partial \mathbf{x}^2} \right) + f(\mathbf{x}, \mathbf{y}), \tag{3.88}$$

in a domain

$$(\mathbf{x}, \mathbf{y}) \in (0, L\_{\times}) \times (0, L\_{\mathbf{y}}), \quad t \in (0, T], \mathbf{y}$$

with <sup>u</sup> D <sup>0</sup> on the boundary and u.x; y; 0/ D I.x; y/ as initial condition.

#### **3.6.1 Discretization**

For generality, it is natural to use a --rule for the time discretization. Standard, second-order accurate finite differences are used for the spatial derivatives. We sample the PDE at a space-time point .i; j; n C <sup>1</sup> <sup>2</sup> / and apply the difference approximations:

$$\begin{aligned} [D\_l u]^{n + \frac{1}{2}} &= \theta [\alpha (D\_x D\_x u + D\_y D\_y u) + f]^{n+1} \\ &+ (1 - \theta) [\alpha (D\_x D\_x u + D\_y D\_y u) + f]^n. \end{aligned} \tag{3.89}$$

Written out,

$$\begin{split} & \frac{u\_{i,j}^{n+1} - u\_{i,j}^{n}}{\Delta t} \\ &= \theta \left( \alpha \left( \frac{u\_{i-1,j}^{n+1} - 2u\_{i,j}^{n+1} + u\_{i+1,j}^{n+1}}{\Delta x^2} + \frac{u\_{i,j-1}^{n+1} - 2u\_{i,j}^{n+1} + u\_{i,j+1}^{n+1}}{\Delta y^2} \right) + f\_{i,j}^{n+1} \right) \\ &+ (1 - \theta) \left( \alpha \left( \frac{u\_{i-1,j}^{n} - 2u\_{i,j}^{n} + u\_{i+1,j}^{n}}{\Delta x^2} + \frac{u\_{i,j-1}^{n} - 2u\_{i,j}^{n} + u\_{i,j+1}^{n}}{\Delta y^2} \right) + f\_{i,j}^{n} \right) . \end{split} \tag{3.90}$$

We collect the unknowns on the left-hand side

$$\begin{split} &u\_{i,j}^{n+1} - \theta \left( F\_{\mathbf{x}} (u\_{i-1,j}^{n+1} - 2u\_{i,j}^{n+1} + u\_{i+1,j}^{n+1}) + F\_{\mathbf{y}} (u\_{i,j-1}^{n+1} - 2u\_{i,j}^{n+1} + u\_{i,j+1}^{n+1}) \right) \\ &= (1 - \theta) \left( F\_{\mathbf{x}} (u\_{i-1,j}^{n} - 2u\_{i,j}^{n} + u\_{i+1,j}^{n}) + F\_{\mathbf{y}} (u\_{i,j-1}^{n} - 2u\_{i,j}^{n} + u\_{i,j+1}^{n}) \right) \\ &\quad + \theta \Delta t f\_{i,j}^{n+1} + (1 - \theta) \Delta t f\_{i,j}^{n} + u\_{i,j}^{n}, \end{split} \tag{3.91}$$


**Fig. 3.14** 3x2 2D mesh

where

$$F\_x = \frac{\alpha \Delta t}{\Delta x^2}, \quad F\_y = \frac{\alpha \Delta t}{\Delta y^2},$$

are the Fourier numbers in x and y direction, respectively.

#### **3.6.2 Numbering of Mesh Points Versus Equations and Unknowns**

The equations (3.91) are coupled at the new time level <sup>n</sup>C1. That is, we must solve a system of (linear) algebraic equations, which we will write as Ac D <sup>b</sup>, where <sup>A</sup> is the coefficient matrix, c is the vector of unknowns, and b is the right-hand side.

Let us examine the equations in Ac <sup>D</sup> <sup>b</sup> on a mesh with Nx <sup>D</sup> <sup>3</sup> and Ny <sup>D</sup> <sup>2</sup> cells in the respective spatial directions. The spatial mesh is depicted in Fig. 3.14. The equations at the boundary just implement the boundary condition <sup>u</sup> D <sup>0</sup>:

$$\begin{aligned} \boldsymbol{u}\_{0,0}^{n+1} &= \boldsymbol{u}\_{1,0}^{n+1} = \boldsymbol{u}\_{2,0}^{n+1} = \boldsymbol{u}\_{3,0}^{n+1} = \boldsymbol{u}\_{0,1}^{n+1} \\ \boldsymbol{=u}\_{3,1}^{n+1} &= \boldsymbol{u}\_{0,2}^{n+1} = \boldsymbol{u}\_{1,2}^{n+1} = \boldsymbol{u}\_{2,2}^{n+1} = \boldsymbol{u}\_{3,2}^{n+1} = \boldsymbol{0} \end{aligned}$$

We are left with two interior points, with <sup>i</sup> D <sup>1</sup>, <sup>j</sup> D <sup>1</sup> and <sup>i</sup> D <sup>2</sup>, <sup>j</sup> D <sup>1</sup>. The corresponding equations are

$$\begin{split} &u\_{i,j}^{n+1} - \theta \left( F\_{\mathbf{x}} (\mathbf{u}\_{i-1,j}^{n+1} - 2u\_{i,j}^{n+1} + u\_{i+1,j}^{n+1}) + F\_{\mathbf{y}} (\mathbf{u}\_{i,j-1}^{n+1} - 2u\_{i,j}^{n+1} + u\_{i,j+1}^{n+1}) \right) \\ &= (1 - \theta) \left( F\_{\mathbf{x}} (\mathbf{u}\_{i-1,j}^{n} - 2u\_{i,j}^{n} + u\_{i+1,j}^{n}) + F\_{\mathbf{y}} (\mathbf{u}\_{i,j-1}^{n} - 2u\_{i,j}^{n} + u\_{i,j+1}^{n}) \right) \\ &\quad + \theta \Delta t f\_{i,j}^{n+1} + (1 - \theta) \Delta t f\_{i,j}^{n} + u\_{i,j}^{n} .\end{split}$$

There are in total 12 unknowns u<sup>n</sup>C<sup>1</sup> i;j for <sup>i</sup> <sup>D</sup> 0; 1; 2; 3 and <sup>j</sup> <sup>D</sup> 0; 1; 2. To solve the equations, we need to form a matrix system Ac D <sup>b</sup>. In that system, the solution vector c can only have one index. Thus, we need a numbering of the unknowns with one index, not two as used in the mesh. We introduce a mapping m.i; j / from a mesh point with indices .i; j / to the corresponding unknown p in the equation system:

$$p = m(i, j) = j(N\_x + 1) + i\ .$$

When i and j run through their values, we see the following mapping to p:

$$\begin{aligned} &(0,0) \to 0, \ (0,1) \to 1, \ (0,2) \to 2, \ (0,3) \to 3, \\ &(1,0) \to 4, \ (1,1) \to 5, \ (1,2) \to 6, \ (1,3) \to 7, \\ &(2,0) \to 8, \ (2,1) \to 9, \ (2,2) \to 10, \ (2,3) \to 11 \dots \end{aligned}$$

That is, we number the points along the <sup>x</sup> axis, starting with <sup>y</sup> D <sup>0</sup>, and then progress one "horizontal" mesh line at a time. In Fig. 3.14 you can see that the .i; j / and the corresponding single index (p) are listed for each mesh point.

We could equally well have numbered the equations in other ways, e.g., let the <sup>j</sup> index be the fastest varying index: <sup>p</sup> <sup>D</sup> m.i; j / <sup>D</sup> i.Ny <sup>C</sup> 1/ <sup>C</sup> <sup>j</sup> .

Let us form the coefficient matrix A, or more precisely, insert a matrix element (according Python's convention with zero as base index) for each of the nonzero elements in <sup>A</sup> (the indices run through the values of <sup>p</sup>, i.e., <sup>p</sup> D 0; : : : ; 11):


Here is a more compact visualization of the coefficient matrix where we insert dots for zeros and bullets for non-zero elements:

It is clearly seen that most of the elements are zero. This is a general feature of coefficient matrices arising from discretizing PDEs by finite difference methods. We say that the matrix is *sparse*.

Let Ap;q be the value of element .p; q/ in the coefficient matrix A, where p and q now correspond to the numbering of the unknowns in the equation system. We have Ap;q <sup>D</sup> <sup>1</sup> for <sup>p</sup> <sup>D</sup> <sup>q</sup> <sup>D</sup> 0; 1; 2; 3; 4; 7; 8; 9; 10; 11, corresponding to all the known boundary values. Let p be m.i; j /, i.e., the single index corresponding to mesh point .i; j /. Then we have

$$A\_{m(i,j),m(i,j)} = A\_{p,p} = 1 + \theta(F\_x + F\_y), \tag{3.92}$$

:

$$A\_{p,m(i-1,j)} = A\_{p,p-1} = -\theta F\_x,\tag{3.93}$$

$$A\_{p,m(i+1,j)} = A\_{p,p+1} = -\theta F\_x,\tag{3.94}$$

$$A\_{p,m(i,j-1)} = A\_{p,p-(N\_x+1)} = -\theta F\_y,\tag{3.95}$$

$$A\_{p,m(i,j+1)} = A\_{p,p+(N\_\lambda+1)} = -\theta F\_y,\tag{3.96}$$

for the equations associated with the two interior mesh points. At these interior points, the single index <sup>p</sup> takes on the specific values <sup>p</sup> D 5; 6, corresponding to the values .1; 1/ and .1; 2/ of the pair .i; j /.

The above values for Ap;q can be inserted in the matrix:


:

The corresponding right-hand side vector in the equation system has the entries bp, where p numbers the equations. We have

$$b\_0 = b\_1 = b\_2 = b\_3 = b\_4 = b\_7 = b\_8 = b\_9 = b\_{10} = b\_{11} = 0,$$

for the boundary values. For the equations associated with the interior points, we get for <sup>p</sup> D 5; 6, corresponding to <sup>i</sup> D 1; 2 and <sup>j</sup> D <sup>1</sup>:

$$\begin{split} b\_{p} &= u\_{i,j}^{n} + (1 - \theta) \left( F\_{\mathbf{x}} (u\_{i-1,j}^{n} - 2u\_{i,j}^{n} + u\_{i+1,j}^{n}) + F\_{\mathbf{y}} (u\_{i,j-1}^{n} - 2u\_{i,j}^{n} + u\_{i,j+1}^{n}) \right) \\ &+ \theta \Delta t f\_{i,j}^{n+1} + (1 - \theta) \Delta t f\_{i,j}^{n} .\end{split}$$

Recall that <sup>p</sup> <sup>D</sup> m.i; j / <sup>D</sup> j.Nx <sup>C</sup> 1/ <sup>C</sup> <sup>j</sup> in this expression.

We can, as an alternative, leave the boundary mesh points out of the matrix system. For a mesh with Nx <sup>D</sup> <sup>3</sup> and Ny <sup>D</sup> <sup>2</sup> there are only two internal mesh points whose unknowns will enter the matrix system. We must now number the unknowns at the interior points:

$$p = (j - 1)(N\_x - 1) + i,$$

for <sup>i</sup> <sup>D</sup> 1; : : : ; Nx <sup>1</sup>, <sup>j</sup> <sup>D</sup> 1; : : : ; Ny <sup>1</sup>.

We can continue with illustrating a bit larger mesh, Nx <sup>D</sup> <sup>4</sup> and Ny <sup>D</sup> <sup>3</sup>, see Fig. 3.15. The corresponding coefficient matrix with dots for zeros and bullets for non-zeroes looks as follows (values at boundary points are included in the equation system):


#### **The coefficient matrix is banded**

Besides being sparse, we observe that the coefficient matrix is *banded*: it has five distinct bands. We have the diagonal Ai;i , the subdiagonal Ai1;j , the superdiagonal Ai;iC<sup>1</sup>, a lower diagonal Ai;i.N xC1/, and an upper diagonal Ai;iC.N xC1/.


**Fig. 3.15** 4x3 2D mesh

The other matrix entries are known to be zero. With Nx <sup>C</sup> <sup>1</sup> <sup>D</sup> Ny <sup>C</sup> <sup>1</sup> <sup>D</sup> <sup>N</sup> , only a fraction 5N <sup>2</sup> of the matrix entries are nonzero, so the matrix is clearly very sparse for relevant N values. The more we can compute with the nonzeros only, the faster the solution methods will potentially be.

#### **3.6.3 Algorithm for Setting Up the Coefficient Matrix**

We looked at a specific mesh in the previous section, formulated the equations, and saw what the corresponding coefficient matrix and right-hand side are. Now our aim is to set up a general algorithm, for any choice of Nx and Ny, that produces the coefficient matrix and the right-hand side vector. We start with a zero matrix and vector, run through each mesh point, and fill in the values depending on whether the mesh point is an interior point or on the boundary.

$$\begin{array}{l} \bullet \quad \text{for } i = 0, \ldots, N\_x \\ \quad - \text{ for } j = 0, \ldots, N\_y \\ \quad \* \quad p = j(N\_x + 1) + i \end{array}$$

 if point .i; j / is on the boundary: Ap;p <sup>D</sup> <sup>1</sup>, bp <sup>D</sup> <sup>0</sup> else: fill Ap;m.i1;j /, Ap;m.iC1;j /, Ap;m.i;j /, Ap;m.i;j 1/, Ap;m.i;j C1/, and bp

To ease the test on whether .i; j / is on the boundary or not, we can split the loops a bit, starting with the boundary line <sup>j</sup> D <sup>0</sup>, then treat the interior lines <sup>1</sup> j <Ny, and finally treat the boundary line <sup>j</sup> D Ny:

	- **–** boundary <sup>i</sup> <sup>D</sup> <sup>0</sup>: <sup>p</sup> <sup>D</sup> j.Nx <sup>C</sup> 1/ <sup>C</sup> <sup>i</sup>, Ap;p <sup>D</sup> <sup>1</sup>
	- **–** for <sup>i</sup> <sup>D</sup> 1; : : : ; Nx <sup>1</sup>
		- interior point <sup>p</sup> <sup>D</sup> j.Nx <sup>C</sup> 1/ <sup>C</sup> <sup>i</sup>
	- fill Ap;m.i1;j /, Ap;m.iC1;j /, Ap;m.i;j /, Ap;m.i;j 1/, Ap;m.i;j C1/, and bp
	- **–** boundary <sup>i</sup> <sup>D</sup> Nx: <sup>p</sup> <sup>D</sup> j.Nx <sup>C</sup> 1/ <sup>C</sup> <sup>i</sup>, Ap;p <sup>D</sup> <sup>1</sup>
	- **–** boundary <sup>j</sup> <sup>D</sup> Ny: <sup>p</sup> <sup>D</sup> j.Nx <sup>C</sup> 1/ <sup>C</sup> <sup>i</sup>, Ap;p <sup>D</sup> <sup>1</sup>

The right-hand side is set up as follows.

	- **–** boundary <sup>i</sup> <sup>D</sup> <sup>0</sup>: <sup>p</sup> <sup>D</sup> j.Nx <sup>C</sup> 1/ <sup>C</sup> <sup>i</sup>, bp <sup>D</sup> <sup>0</sup>
	- **–** for <sup>i</sup> <sup>D</sup> 1; : : : ; Nx <sup>1</sup>
		- interior point <sup>p</sup> <sup>D</sup> j.Nx <sup>C</sup> 1/ <sup>C</sup> <sup>i</sup>
		- fill bp
	- **–** boundary <sup>i</sup> <sup>D</sup> Nx: <sup>p</sup> <sup>D</sup> j.Nx <sup>C</sup> 1/ <sup>C</sup> <sup>i</sup>, bp <sup>D</sup> <sup>0</sup>

#### **3.6.4 Implementation with a Dense Coefficient Matrix**

The goal now is to map the algorithms in the previous section to Python code. One should, for computational efficiency reasons, take advantage of the fact that the coefficient matrix is sparse and/or banded, i.e., take advantage of all the zeros. However, we first demonstrate how to fill an <sup>N</sup> - N dense square matrix, where <sup>N</sup> is the number of unknowns, here <sup>N</sup> <sup>D</sup> .Nx <sup>C</sup> 1/.Ny <sup>C</sup> 1/. The dense matrix is much easier to understand than the sparse matrix case.

```
import numpy as np
def solver_dense(
    I, a, f, Lx, Ly, Nx, Ny, dt, T, theta=0.5, user_action=None):
    """
    Solve u_t = a*(u_xx + u_yy) + f, u(x,y,0)=I(x,y), with u=0
    on the boundary, on [0,Lx]x[0,Ly]x[0,T], with time step dt,
    using the theta-scheme.
    """
```

```
x = np.linspace(0, Lx, Nx+1) # mesh points in x dir
y = np.linspace(0, Ly, Ny+1) # mesh points in y dir
dx = x[1] - x[0]
dy = y[1] - y[0]
dt = float(dt) # avoid integer division
Nt = int(round(T/float(dt)))
t = np.linspace(0, Nt*dt, Nt+1) # mesh points in time
# Mesh Fourier numbers in each direction
Fx = a*dt/dx**2
Fy = a*dt/dy**2
```
The u<sup>n</sup>C<sup>1</sup> i;j and u<sup>n</sup> i;j mesh functions are represented by their spatial values at the mesh points:

```
u = np.zeros((Nx+1, Ny+1)) # unknown u at new time level
u_n = np.zeros((Nx+1, Ny+1)) # u at the previous time level
```
It is a good habit (for extensions) to introduce index sets for all mesh points:

Ix = range(0, Nx+1) Iy = range(0, Ny+1) It = range(0, Nt+1)

The initial condition is easy to fill in:

```
# Load initial condition into u_n
for i in Ix:
    for j in Iy:
        u_n[i,j] = I(x[i], y[j])
```
The memory for the coefficient matrix and right-hand side vector is allocated by

N = (Nx+1)\*(Ny+1) # no of unknowns A = np.zeros((N, N)) b = np.zeros(N)

The filling of A goes like this:

```
m = lambda i, j: j*(Nx+1) + i
# Equations corresponding to j=0, i=0,1,... (u known)
j = 0
for i in Ix:
    p = m(i,j); A[p, p]=1
```

```
# Loop over all internal mesh points in y diretion
# and all mesh points in x direction
for j in Iy[1:-1]:
   i = 0; p = m(i,j); A[p, p]=1 # Boundary
   for i in Ix[1:-1]: # Interior points
       p = m(i,j)
       A[p, m(i,j-1)] = - theta*Fy
       A[p, m(i-1,j)] = - theta*Fx
       A[p, p] = 1 + 2*theta*(Fx+Fy)
       A[p, m(i+1,j)] = - theta*Fx
       A[p, m(i,j+1)] = - theta*Fy
   i = Nx; p = m(i,j); A[p, p]=1 # Boundary
# Equations corresponding to j=Ny, i=0,1,... (u known)
j = Ny
for i in Ix:
   p = m(i,j); A[p, p]=1
```
Since A is independent of time, it can be filled once and for all before the time loop. The right-hand side vector must be filled at each time level inside the time loop:

```
import scipy.linalg
for n in It[0:-1]:
   # Compute b
   j = 0
   for i in Ix:
       p = m(i,j); b[p]=0 # Boundary
   for j in Iy[1:-1]:
       i = 0; p = m(i,j); b[p]=0 # Boundary
       for i in Ix[1:-1]: # Interior points
           p = m(i,j)
           b[p] = u_n[i,j]+\
             (1-theta)*(
             Fx*(u_n[i+1,j] - 2*u_n[i,j] + u_n[i-1,j]) +\
             Fy*(u_n[i,j+1] - 2*u_n[i,j] + u_n[i,j-1]))\
               + theta*dt*f(i*dx,j*dy,(n+1)*dt)+\
             (1-theta)*dt*f(i*dx,j*dy,n*dt)
       i = Nx; p = m(i,j); b[p]=0 # Boundary
   j = Ny
   for i in Ix:
       p = m(i,j); b[p]=0 # Boundary
   # Solve matrix system A*c = b
   c = scipy.linalg.solve(A, b)
   # Fill u with vector c
   for i in Ix:
       for j in Iy:
           u[i,j] = c[m(i,j)]
   # Update u_n before next step
   u_n, u = u, u_n
```
We use solve from scipy.linalg and not from numpy.linalg. The difference is stated below.

#### scipy.linalg **versus** numpy.linalg

Quote from the SciPy documentation2:

scipy.linalg contains all the functions in numpy.linalg plus some other more advanced ones not contained in numpy.linalg.

Another advantage of using scipy.linalg over numpy.linalg is that it is always compiled with BLAS/LAPACK support, while for NumPy this is optional. Therefore, the SciPy version might be faster depending on how NumPy was installed.

Therefore, unless you don't want to add SciPy as a dependency to your NumPy program, use scipy.linalg instead of numpy.linalg.

The code shown above is available in the solver\_dense function in the file diffu2D\_u0.py, differing only in the boundary conditions, which in the code can be an arbitrary function along each side of the domain.

We do not bother to look at vectorized versions of filling A since a dense matrix is just used of pedagogical reasons for the very first implementation. Vectorization will be treated when A has a sparse matrix representation, as in Sect. 3.6.7.

## **How to debug the computation of** A **and** b

A good starting point for debugging the filling of A and b is to choose a very coarse mesh, say Nx <sup>D</sup> Ny <sup>D</sup> <sup>2</sup>, where there is just one internal mesh point, compute the equations by hand, and print out A and b for comparison in the code. If wrong elements in A or b occur, print out each assignment to elements in A and b inside the loops and compare with what you expect.

To let the user store, analyze, or visualize the solution at each time level, we include a callback function, named user\_action, to be called before the time loop and in each pass in that loop. The function has the signature

user\_action(u, x, xv, y, yv, t, n)

where u is a two-dimensional array holding the solution at time level n and time t[n]. The x and y coordinates of the mesh points are given by the arrays x and y, respectively. The arrays xv and yv are vectorized representations of the mesh points such that vectorized function evaluations can be invoked. The xv and yv arrays are defined by

xv = x[:,np.newaxis] yv = y[np.newaxis,:]

One can then evaluate, e.g., f .x; y; t/ at all internal mesh points at time level n by first evaluating f at all points,

f\_a = f(xv, yv, t[n])

<sup>2</sup> http://docs.scipy.org/doc/scipy/reference/tutorial/linalg.html

and then use slices to extract a view of the values at the internal mesh points: f\_a[1:-1,1:-1]. The next section features an example on writing a user\_action callback function.

#### **3.6.5 Verification: Exact Numerical Solution**

A good test example to start with is one that preserves the solution <sup>u</sup> D <sup>0</sup>, i.e., <sup>f</sup> D <sup>0</sup> and I.x; y/ D <sup>0</sup>. This trivial solution can uncover some bugs.

The first real test example is based on having an exact solution of the discrete equations. This solution is linear in time and quadratic in space:

$$u(\mathbf{x}, \mathbf{y}, t) = \mathfrak{H}\mathbf{x}(L\_{\mathbf{x}} - \mathbf{x})\mathbf{y}(\mathbf{y} - L\_{\mathbf{y}})\ .$$

Inserting this manufactured solution in the PDE shows that the source term f must be

$$f(\mathbf{x}, \mathbf{y}, t) = \mathbf{S} \mathbf{x} (L\_{\mathbf{x}} - \mathbf{x}) \mathbf{y} (\mathbf{y} - L\_{\mathbf{y}}) + 10 \mathbf{a} t \left( \mathbf{x} (L\_{\mathbf{x}} - \mathbf{x}) + \mathbf{y} (\mathbf{y} - L\_{\mathbf{y}}) \right).$$

We can use the user\_action function to compare the numerical solution with the exact solution at each time level. A suitable helper function for checking the solution goes like this:

```
def quadratic(theta, Nx, Ny):
    def u_exact(x, y, t):
        return 5*t*x*(Lx-x)*y*(Ly-y)
    def I(x, y):
        return u_exact(x, y, 0)
    def f(x, y, t):
        return 5*x*(Lx-x)*y*(Ly-y) + 10*a*t*(y*(Ly-y)+x*(Lx-x))
    # Use rectangle to detect errors in switching i and j in scheme
    Lx = 0.75
    Ly = 1.5
    a = 3.5
    dt = 0.5
    T = 2
    def assert_no_error(u, x, xv, y, yv, t, n):
        """Assert zero error at all mesh points."""
        u_e = u_exact(xv, yv, t[n])
        diff = abs(u - u_e).max()
        tol = 1E-12
        msg = 'diff=%g, step %d, time=%g' % (diff, n, t[n])
        print msg
        assert diff < tol, msg
    solver_dense(
        I, a, f, Lx, Ly, Nx, Ny,
        dt, T, theta, user_action=assert_no_error)
```
A true test function for checking the quadratic solution for several different meshes and values can take the form

```
def test_quadratic():
    # For each of the three schemes (theta = 1, 0.5, 0), a series of
    # meshes are tested (Nx > Ny and Nx < Ny)
    for theta in [1, 0.5, 0]:
        for Nx in range(2, 6, 2):
            for Ny in range(2, 6, 2):
                print 'testing for %dx%d mesh' % (Nx, Ny)
                quadratic(theta, Nx, Ny)
```
#### **3.6.6 Verification: Convergence Rates**

For 2D verification with convergence rate computations, the expressions and computations just build naturally on what we saw for 1D diffusion. Truncation error analysis and other forms of error analysis point to a numerical error formula like

$$E = C\_l \Delta t^p + C\_\chi \Delta x^2 + C\_\mathcal{y} \Delta \mathcal{y}^2,$$

where p, Ct , Cx, and Cy are constants. Often, the analysis of a Crank-Nicolson method can show that <sup>p</sup> D <sup>2</sup>, while the Forward and Backward Euler schemes have <sup>p</sup> D <sup>1</sup>.

When checking the error formula empirically, we need to reduce it to a form <sup>E</sup> D C hr with a single discretization parameter h and some rate r to be estimated. For the Backward Euler method, where <sup>p</sup> D <sup>1</sup>, we can introduce a single discretization parameter according to

$$h = \Delta \mathbf{x}^2 = \Delta \mathbf{y}^2, \quad h = K^{-1} \Delta t, \dots$$

where K is a constant. The error formula then becomes

$$E = C\_l Kh + C\_x h + C\_y h = Ch, \quad C = C\_l K + C\_x + C\_y \dots$$

The simplest choice is obviously <sup>K</sup> D <sup>1</sup>. With the Forward Euler method, however, stability requires t D hK h=.4˛/, so <sup>K</sup> 1=.4˛/.

For the Crank-Nicolson method, <sup>p</sup> D <sup>2</sup>, and we can simply choose

$$h = \Delta x = \Delta y = \Delta t,$$

since there is no restriction on t in terms of x and y.

A frequently used error measure is the `2 norm of the error mesh point values. Section 2.2.3 and the formula (2.26) shows the error measure for a 1D timedependent problem. The extension to the current 2D problem reads

$$E = \left(\Delta t \,\Delta x \Delta y \sum\_{n=0}^{N\_l} \sum\_{i=0}^{N\_x} \sum\_{j=0}^{N\_y} (u\_\mathbf{c}(\mathbf{x}\_i, y\_j, t\_n) - u\_{i,j}^n)^2\right)^{\frac{1}{2}}.$$

One attractive manufactured solution is

$$u\_{\mathbf{c}} = e^{-pl} \sin(k\_{\mathbf{x}} \mathbf{x}) \sin(k\_{\mathbf{y}} \mathbf{y}), \quad k\_{\mathbf{x}} = \frac{\pi}{L\_{\mathbf{x}}}, k\_{\mathbf{y}} = \frac{\pi}{L\_{\mathbf{y}}},$$

where p can be arbitrary. The required source term is

$$f = (\alpha(k\_x^2 + k\_y^2) - p)u\_{\mathfrak{C}}\dots$$

The function convergence\_rates in diffu2D\_u0.py implements a convergence rate test. Two potential difficulties are important to be aware of:


#### **3.6.7 Implementation with a Sparse Coefficient Matrix**

We used a sparse matrix implementation in Sect. 3.2.2 for a 1D problem with a tridiagonal matrix. The present matrix, arising from a 2D problem, has five diagonals, but we can use the same sparse matrix data structure scipy.sparse.diags.

**Understanding the diagonals** Let us look closer at the diagonals in the example with a <sup>4</sup> - 3 mesh as depicted in Fig. 3.15 and its associated matrix visualized by dots for zeros and bullets for nonzeros. From the example mesh, we may generalize to an Nx -Ny mesh.


The main diagonal has <sup>N</sup> <sup>D</sup> .Nx <sup>C</sup> 1/.Ny <sup>C</sup> 1/ elements, while the sub- and super-diagonals have <sup>N</sup> <sup>1</sup> elements. By looking at the matrix above, we realize that the lower diagonal starts in row Nx <sup>C</sup> <sup>1</sup> and goes to row <sup>N</sup> , so its length is <sup>N</sup> .Nx <sup>C</sup> 1/. Similarly, the upper diagonal starts at row 0 and lasts to row <sup>N</sup> .Nx <sup>C</sup> 1/, so it has the same length. Based on this information, we declare the diagonals by

```
main = np.zeros(N) # diagonal
lower = np.zeros(N-1) # subdiagonal
upper = np.zeros(N-1) # superdiagonal
lower2 = np.zeros(N-(Nx+1)) # lower diagonal
upper2 = np.zeros(N-(Nx+1)) # upper diagonal
b = np.zeros(N) # right-hand side
```
**Filling the diagonals** We run through all mesh points and fill in elements on the various diagonals. The line of mesh points corresponding to <sup>j</sup> D <sup>0</sup> are all on the boundary, and only the main diagonal gets a contribution:

```
m = lambda i, j: j*(Nx+1) + i
j = 0; main[m(0,j):m(Nx+1,j)] = 1 # j=0 boundary line
```
Then we run through all interior <sup>j</sup> D const lines of mesh points. The first and the last point on each line, <sup>i</sup> D <sup>0</sup> and <sup>i</sup> D Nx, correspond to boundary points:

```
for j in Iy[1:-1]: # Interior mesh lines j=1,...,Ny-1
   i = 0; main[m(i,j)] = 1
   i = Nx; main[m(i,j)] = 1 # Boundary
```
For the interior mesh points <sup>i</sup> <sup>D</sup> 1; : : : ; Nx <sup>1</sup> on a mesh line <sup>y</sup> <sup>D</sup> const we can start with the main diagonal. The entries to be filled go from <sup>i</sup> <sup>D</sup> <sup>1</sup> to <sup>i</sup> <sup>D</sup> Nx <sup>1</sup> so the relevant slice in the main vector is m(1,j):m(Nx,j):

main[m(1,j):m(Nx,j)] = 1 + 2\*theta\*(Fx+Fy)

The upper array for the superdiagonal has its index 0 corresponding to row 0 in the matrix, and the array entries to be set go from m.1; j / to m.Nx 1; j /:

upper[m(1,j):m(Nx,j)] = - theta\*Fx

The subdiagonal (lower array), however, has its index 0 corresponding to row 1, so there is an offset of 1 in indices compared to the matrix. The first nonzero occurs (interior point) at a mesh line <sup>j</sup> D const corresponding to matrix row m.1; j /, and the corresponding array index in lower is then m.1; j /. To fill the entries from m.1; j / to m.Nx 1; j / we set the following slice in lower:

```
lower_offset = 1
lower[m(1,j)-lower_offset:m(Nx,j)-lower_offset]=- theta*Fx
```
For the upper diagonal, its index 0 corresponds to matrix row 0, so there is no offset and we can set the entries correspondingly to upper:

upper2[m(1,j):m(Nx,j)] = - theta\*Fy

The lower2 diagonal, however, has its first index 0 corresponding to row Nx <sup>C</sup> <sup>1</sup>, so here we need to subtract the offset Nx <sup>C</sup> <sup>1</sup>:

```
lower2_offset = Nx+1
lower2[m(1,j)-lower2_offset:m(Nx,j)-lower2_offset]=- theta*Fy
```
We can now summarize the above code lines for setting the entries in the sparse matrix representation of the coefficient matrix:

```
lower_offset = 1
lower2_offset = Nx+1
m = lambda i, j: j*(Nx+1) + i
j = 0; main[m(0,j):m(Nx+1,j)] = 1 # j=0 boundary line
for j in Iy[1:-1]: # Interior mesh lines j=1,...,Ny-1
   i = 0; main[m(i,j)] = 1 # Boundary
   i = Nx; main[m(i,j)] = 1 # Boundary
   # Interior i points: i=1,...,N_x-1
   lower2[m(1,j)-lower2_offset:m(Nx,j)-lower2_offset]=- theta*Fy
   lower[m(1,j)-lower_offset:m(Nx,j)-lower_offset]=- theta*Fx
   main[m(1,j):m(Nx,j)] = 1 + 2*theta*(Fx+Fy)
   upper[m(1,j):m(Nx,j)] = - theta*Fx
   upper2[m(1,j):m(Nx,j)] = - theta*Fy
j = Ny; main[m(0,j):m(Nx+1,j)] = 1 # Boundary line
```
The next task is to create the sparse matrix from these diagonals:

```
import scipy.sparse
A = scipy.sparse.diags(
    diagonals=[main, lower, upper, lower2, upper2],
    offsets=[0, -lower_offset, lower_offset,
             -lower2_offset, lower2_offset],
    shape=(N, N), format='csr')
```
**Filling the right-hand side; scalar version** Setting the entries in the right-hand side is easier, since there are no offsets in the array to take into account. The righthand side is in fact similar to the one previously shown, when we used a dense matrix representation (the right-hand side vector is, of course, independent of what type of representation we use for the coefficient matrix). The complete time loop goes as follows.

```
import scipy.sparse.linalg
for n in It[0:-1]:
   # Compute b
   j = 0
   for i in Ix:
      p = m(i,j); b[p]=0 # Boundary
```

```
for j in Iy[1:-1]:
   i = 0; p = m(i,j); b[p]=0 # Boundary
   for i in Ix[1:-1]:
       p = m(i,j) # Interior
       b[p] = u_n[i,j]+\
        (1-theta)*(
        Fx*(u_n[i+1,j] - 2*u_n[i,j] + u_n[i-1,j]) +\
        Fy*(u_n[i,j+1] - 2*u_n[i,j] + u_n[i,j-1]))\
          + theta*dt*f(i*dx,j*dy,(n+1)*dt)+\
        (1-theta)*dt*f(i*dx,j*dy,n*dt)
   i = Nx; p = m(i,j); b[p]=0 # Boundary
j = Ny
for i in Ix:
   p = m(i,j); b[p]=0 # Boundary
# Solve matrix system A*c = b
c = scipy.sparse.linalg.spsolve(A, b)
# Fill u with vector c
for i in Ix:
   for j in Iy:
       u[i,j] = c[m(i,j)]
# Update u_n before next step
u_n, u = u, u_n
```
**Filling the right-hand side; vectorized version.** Since we use a sparse matrix and try to speed up the computations, we should examine the loops and see if some can be easily removed by vectorization. In the filling of A we have already used vectorized expressions at each <sup>j</sup> D const line of mesh points. We can very easily do the same in the code above and remove the need for loops over the i index:

```
for n in It[0:-1]:
   # Compute b, vectorized version
   # Precompute f in array so we can make slices
   f_a_np1 = f(xv, yv, t[n+1])
   f_a_n = f(xv, yv, t[n])
   j = 0; b[m(0,j):m(Nx+1,j)] = 0 # Boundary
   for j in Iy[1:-1]:
       i = 0; p = m(i,j); b[p]=0 # Boundary
       i = Nx; p = m(i,j); b[p]=0 # Boundary
       imin = Ix[1]
       imax = Ix[-1] # for slice, max i index is Ix[-1]-1
       b[m(imin,j):m(imax,j)] = u_n[imin:imax,j]+\
             (1-theta)*(Fx*(
         u_n[imin+1:imax+1,j] -
       2*u_n[imin:imax,j]+\
         u_n[imin-1:imax-1,j]) +
                        Fy*(
         u_n[imin:imax,j+1] -
       2*u_n[imin:imax,j] +
         u_n[imin:imax,j-1])) + \
           theta*dt*f_a_np1[imin:imax,j]+\
         (1-theta)*dt*f_a_n[imin:imax,j]
   j = Ny; b[m(0,j):m(Nx+1,j)] = 0 # Boundary
```

```
# Solve matrix system A*c = b
c = scipy.sparse.linalg.spsolve(A, b)
# Fill u with vector c
u[:,:] = c.reshape(Ny+1,Nx+1).T
# Update u_n before next step
u_n, u = u, u_n
```
The most tricky part of this code snippet is the loading of values from the onedimensional array c into the two-dimensional array u. With our numbering of unknowns from left to right along "horizontal" mesh lines, the correct reordering of the one-dimensional array c as a two-dimensional array requires first a reshaping to an (Ny+1,Nx+1) two-dimensional array and then taking the transpose. The result is an (Nx+1,Ny+1) array compatible with u both in size and appearance of the function values.

The spsolve function in scipy.sparse.linalg is an efficient version of Gaussian elimination suited for matrices described by diagonals. The algorithm is known as *sparse Gaussian elimination*, and spsolve calls up a well-tested C code called SuperLU3.

The complete code utilizing spsolve is found in the solver\_sparse function in the file diffu2D\_u0.py.

**Verification** We can easily extend the function quadratic from Sect. 3.6.5 to include a test of the solver\_sparse function as well.

```
def quadratic(theta, Nx, Ny):
    ...
    t, cpu = solver_sparse(
        I, a, f, Lx, Ly, Nx, Ny,
        dt, T, theta, user_action=assert_no_error)
```
#### **3.6.8 The Jacobi Iterative Method**

So far we have created a matrix and right-hand side of a linear system Ac D <sup>b</sup> and solved the system for c by calling an exact algorithm based on Gaussian elimination. A much simpler implementation, which requires no memory for the coefficient matrix A, arises if we solve the system by *iterative* methods. These methods are only approximate, and the core algorithm is repeated many times until the solution is considered to be converged.

**Numerical scheme and linear system** To illustrate the idea of the Jacobi method, we simplify the numerical scheme to the Backward Euler case, -D <sup>1</sup>, so there are

<sup>3</sup> http://crd-legacy.lbl.gov/~xiaoye/SuperLU/

fewer terms to write:

$$\begin{aligned} &u\_{i,j}^{n+1} - \left(F\_{\ge} \left(u\_{i-1,j}^{n+1} - 2u\_{i,j}^{n+1} + u\_{i+1,j}^{n+1}\right) + F\_{\ge} \left(u\_{i,j-1}^{n+1} - 2u\_{i,j}^{n+1} + u\_{i,j+1}^{n+1}\right)\right) \\ &= u\_{i,j}^{n} + \Delta t f\_{i,j}^{n+1}. \end{aligned} \tag{3.97}$$

The idea of the *Jacobi* iterative method is to introduce an iteration, here with index r, where we in each iteration treat u<sup>n</sup>C<sup>1</sup> i;j as unknown, but use values from the previous iteration for the other unknowns u<sup>n</sup>C<sup>1</sup> <sup>i</sup>˙1;j ˙<sup>1</sup>.

**Iterations** Let u<sup>n</sup>C1;r i;j be the approximation to u<sup>n</sup>C<sup>1</sup> i;j in iteration r, for all relevant i and j indices. We first solve with respect to u<sup>n</sup>C<sup>1</sup> i;j to get the equation to solve:

$$\begin{split} u\_{i,j}^{n+1} &= (1 + 2F\_{\boldsymbol{x}} + 2F\_{\boldsymbol{y}})^{-1} \left( F\_{\boldsymbol{x}} \left( u\_{i-1,j}^{n+1} + u\_{i+1,j}^{n+1} \right) + F\_{\boldsymbol{y}} \left( u\_{i,j-1}^{n+1} + u\_{i,j+1}^{n+1} \right) \right) \\ &+ u\_{i,j}^{n} + \Delta t f\_{i,j}^{n+1} . \end{split} \tag{3.98}$$

The iteration is introduced by using iteration index r, for computed values, on the right-hand side and <sup>r</sup> C <sup>1</sup> (unknown in this iteration) on the left-hand side:

$$\begin{split} u\_{i,j}^{n+1,r+1} &= \left(1 + 2F\_{\ge} + 2F\_{\ge}\right)^{-1} \left(F\_{\ge} \left(u\_{i-1,j}^{n+1,r} + u\_{i+1,j}^{n+1,r}\right) + F\_{\ge} \left(u\_{i,j-1}^{n+1,r} + u\_{i,j+1}^{n+1,r}\right)\right) \\ &+ u\_{i,j}^{n} + \Delta t f\_{i,j}^{n+1} .\end{split} \tag{3.99}$$

**Initial guess** We start the iteration with the computed values at the previous time level:

$$u\_{i,j}^{n+1,0} = u\_{i,j}^n, \quad i = 0, \ldots, N\_{\ge}, \ j = 0, \ldots, N\_{\ge} \,. \tag{3.100}$$

**Relaxation** A common technique in iterative methods is to introduce a *relaxation*, which means that the new approximation is a weighted mean of the approximation as suggested by the algorithm and the previous approximation. Naming the quantity on the left-hand side of (3.99) as u<sup>n</sup>C1; i;j , a new approximation based on relaxation reads

$$u^{n+1,r+1} = \alpha u\_{i,j}^{n+1,\*} + (1-\alpha)u\_{i,j}^{n+1,r}.\tag{3.101}$$

Under-relaxation means !<1, while over-relaxation has !>1.

**Stopping criteria** The iteration can be stopped when the change from one iteration to the next is sufficiently small ( ), using either an infinity norm,

$$\max\_{i,j} \left| u\_{i,j}^{n+1,r+1} - u\_{i,j}^{n+1,r} \right| \le \epsilon,\tag{3.102}$$

or an L<sup>2</sup> norm,

$$\left(\Delta x \Delta y \sum\_{i,j} (u\_{i,j}^{n+1,r+1} - u\_{i,j}^{n+1,r})^2\right)^{\frac{1}{2}} \le \epsilon \,. \tag{3.103}$$

Another widely used criterion measures how well the equations are solved by looking at the residual (essentially <sup>b</sup> Ac<sup>r</sup>C<sup>1</sup> if <sup>c</sup><sup>r</sup>C<sup>1</sup> is the approximation to the solution in iteration <sup>r</sup> C <sup>1</sup>). The residual, defined in terms of the finite difference stencil, is

$$R\_{i,j} = u\_{i,j}^{n+1,r+1} - \left( F\_\chi \left( u\_{i-1,j}^{n+1,r+1} - 2u\_{i,j}^{n+1,r+1} + u\_{i+1,j}^{n+1,r+1} \right) \right)$$

$$+ F\_\chi \left( u\_{i,j-1}^{n+1,r+1} - 2u\_{i,j}^{n+1,r+1} + u\_{i,j+1}^{n+1,r+1} \right)$$

$$- u\_{i,j}^n - \Delta t f\_{i,j}^{n+1} \,. \tag{3.104}$$

One can then iterate until the norm of the mesh function Ri;j is less than some tolerance:

$$\left(\Delta x \Delta y \sum\_{i,j} R\_{i,j}^2\right)^{\frac{1}{2}} \le \epsilon \,. \tag{3.105}$$

**Code-friendly notation** To make the mathematics as close as possible to what we will write in a computer program, we may introduce some new notation: ui;j is a short notation for u<sup>n</sup>C1;rC<sup>1</sup> i;j , u i;j is a short notation for <sup>u</sup><sup>n</sup>C1;r i;j , and <sup>u</sup>.s/ i;j denotes u<sup>n</sup>C1<sup>s</sup> i;j . That is, ui;j is the unknown, u i;j is its most recently computed approximation, and s counts time levels backwards in time. The Jacobi method (3.99) takes the following form with the new notation:

$$u\_{i,j}^{\*} = (1 + 2F\_x + 2F\_y)^{-1} \left( \left( F\_x(u\_{i-1,j}^{-} + u\_{i+1,j}^{-}) + F\_y(u\_{i,j-1}^{-} + u\_{i,j+1}^{-}) \right) \right)$$
 
$$+ u\_{i,j}^{(1)} + \Delta t f\_{i,j}^{n+1} \Big). \tag{3.106}$$

**Generalization of the scheme** We can also quite easily introduce the rule for discretization in time and write up the Jacobi iteration in that case as well:

$$\begin{split} u\_{i,j}^{\*} = (1 + 2\theta(F\_x + F\_y))^{-1} \Big( \theta \left( F\_x(u\_{i-1,j}^- + u\_{i+1,j}^-) + F\_y(u\_{i,j-1}^- + u\_{i,j+1}^-) \right) \\ &+ u\_{i,j}^{(1)} + \theta \Delta t f\_{i,j}^{n+1} + (1 - \theta) \Delta t f\_{i,j}^n \\ &+ (1 - \theta) \Big( F\_x(u\_{i-1,j}^{(1)} - 2u\_{i,j}^{(1)} + u\_{i+1,j}^{(1)}) \\ &+ F\_y(u\_{i,j-1}^{(1)} - 2u\_{i,j}^{(1)} + u\_{i,j+1}^{(1)}) \Big) . \end{split} \tag{3.107}$$

The final update of u applies relaxation:

$$u\_{i,j} = \alpha u\_{i,j}^\* + (1 - \alpha)u\_{i,j}^- \dots$$

#### **3.6.9 Implementation of the Jacobi Method**

The Jacobi method needs no coefficient matrix and right-hand side vector, but it needs an array for u in the previous iteration. We call this array u\_, using the notation at the end of the previous section (at the same time level). The unknown itself is called u, while u\_n is the computed solution one time level back in time. With a rule in time, the time loop can be coded like this:

```
for n in It[0:-1]:
    # Solve linear system by Jacobi iteration at time level n+1
    u_[:,:] = u_n # Start value
    converged = False
    r = 0
    while not converged:
        if version == 'scalar':
            j = 0
            for i in Ix:
                u[i,j] = U_0y(t[n+1]) # Boundary
            for j in Iy[1:-1]:
                i = 0; u[i,j] = U_0x(t[n+1]) # Boundary
                i = Nx; u[i,j] = U_Lx(t[n+1]) # Boundary
        # Interior points
                for i in Ix[1:-1]:
                    u_new = 1.0/(1.0 + 2*theta*(Fx + Fy))*(theta*(
                        Fx*(u_[i+1,j] + u_[i-1,j]) +
                        Fy*(u_[i,j+1] + u_[i,j-1])) + \
                    u_n[i,j]+\
                    (1-theta)*(Fx*(
                    u_n[i+1,j] - 2*u_n[i,j] + u_n[i-1,j]) +
                      Fy*(
                    u_n[i,j+1] - 2*u_n[i,j] + u_n[i,j-1]))\
                      + theta*dt*f(i*dx,j*dy,(n+1)*dt)+\
                    (1-theta)*dt*f(i*dx,j*dy,n*dt))
                    u[i,j] = omega*u_new + (1-omega)*u_[i,j]
            j = Ny
            for i in Ix:
                u[i,j] = U_Ly(t[n+1]) # Boundary
        elif version == 'vectorized':
            j = 0; u[:,j] = U_0y(t[n+1]) # Boundary
            i = 0; u[i,:] = U_0x(t[n+1]) # Boundary
            i = Nx; u[i,:] = U_Lx(t[n+1]) # Boundary
            j = Ny; u[:,j] = U_Ly(t[n+1]) # Boundary
        # Internal points
            f_a_np1 = f(xv, yv, t[n+1])
            f_a_n = f(xv, yv, t[n])
            u_new = 1.0/(1.0 + 2*theta*(Fx + Fy))*(theta*(Fx*(
              u_[2:,1:-1] + u_[:-2,1:-1]) +
                Fy*(
              u_[1:-1,2:] + u_[1:-1,:-2])) +\
            u_n[1:-1,1:-1] + \
              (1-theta)*(Fx*(
              u_n[2:,1:-1] - 2*u_n[1:-1,1:-1] + u_n[:-2,1:-1]) +\
                Fy*(
              u_n[1:-1,2:] - 2*u_n[1:-1,1:-1] + u_n[1:-1,:-2]))\
              + theta*dt*f_a_np1[1:-1,1:-1] + \
              (1-theta)*dt*f_a_n[1:-1,1:-1])
```

```
u[1:-1,1:-1] = omega*u_new + (1-omega)*u_[1:-1,1:-1]
    r += 1
    converged = np.abs(u-u_).max() < tol or r >= max_iter
    u_[:,:] = u
# Update u_n before next step
u_n, u = u, u_n
```
The vectorized version should be quite straightforward to understand once one has an understanding of how a standard 2D finite stencil is vectorized.

The first natural verification is to use the test problem in the function quadratic from Sect. 3.6.5. This problem is known to have no approximation error, but any iterative method will produce an approximate solution with unknown error. For a tolerance 10<sup>k</sup> in the iterative method, we can, e.g., use a slightly larger tolerance 10.k1/ for the difference between the exact and the computed solution.

```
def quadratic(theta, Nx, Ny):
    ...
    def assert_small_error(u, x, xv, y, yv, t, n):
        """Assert small error for iterative methods."""
        u_e = u_exact(xv, yv, t[n])
        diff = abs(u - u_e).max()
        tol = 1E-4
        msg = 'diff=%g, step %d, time=%g' % (diff, n, t[n])
        assert diff < tol, msg
    for version in 'scalar', 'vectorized':
        for theta in 1, 0.5:
            print 'testing Jacobi, %s version, theta=%g' % \
                  (version, theta)
            t, cpu = solver_Jacobi(
                I=I, a=a, f=f, Lx=Lx, Ly=Ly, Nx=Nx, Ny=Ny,
                dt=dt, T=T, theta=theta,
                U_0x=0, U_0y=0, U_Lx=0, U_Ly=0,
                user_action=assert_small_error,
                version=version, iteration='Jacobi',
                omega=1.0, max_iter=100, tol=1E-5)
```
Even for a very coarse <sup>4</sup>-4 mesh, the Jacobi method requires 26 iterations to reach a tolerance of 10<sup>5</sup>, which is quite many iterations, given that there are only 25 unknowns.

#### **3.6.10 Test Problem: Diffusion of a Sine Hill**

It can be shown that

$$u\_{\mathbf{c}} = A e^{-a\pi^2 (L\_x^{-2} + L\_y^{-2})t} \sin\left(\frac{\pi}{L\_x}\chi\right) \sin\left(\frac{\pi}{L\_y}\chi\right),\tag{3.108}$$

is a solution of the 2D homogeneous diffusion equation ut <sup>D</sup> ˛.uxx <sup>C</sup> uyy/ in a rectangle Œ0; Lx -Œ0; Ly, for any value of the amplitude A. This solution vanishes at the boundaries, and the initial condition is the product of two sines. We may choose <sup>A</sup> D <sup>1</sup> for simplicity.

It is difficult to know if our solver based on the Jacobi method works properly since we are faced with two sources of errors: one from the discretization, E, and one from the iterative Jacobi method, Ei . The total error in the computed u can be represented as

$$E\_u = E\_\Delta + E\_i \dots$$

One error measure is to look at the maximum value, which is obtained for the midpoint <sup>x</sup> <sup>D</sup> Lx=2 and <sup>y</sup> D Lx=2. This midpoint is represented in the discrete <sup>u</sup> if Nx and Ny are even numbers. We can then compute Eu as Eu D j max <sup>u</sup><sup>e</sup> max <sup>u</sup>j, when we know an exact solution ue of the problem.

What about E? If we use the maximum value as a measure of the error, we have in fact analytical insight into the approximation error in this particular problem. According to Sect. 3.3.9, the exact solution (3.108) of the PDE problem is also an exact solution of the discrete equations, except that the damping factor in time is different. More precisely, (3.66) and (3.67) are solutions of the discrete problem for - D <sup>1</sup> (Backward Euler) and - D <sup>1</sup> <sup>2</sup> (Crank-Nicolson), respectively. The factors raised to the power n is the numerical amplitude, and the errors in these factors become

$$\begin{aligned} E\_{\Delta} &= e^{-\alpha k^2 I} - \left( \frac{1 - 2(F\_x \sin^2 p\_x + F\_x \sin^2 p\_y)}{1 + 2(F\_x \sin^2 p\_x + F\_x \sin^2 p\_y)} \right)^n, \quad \theta = \frac{1}{2}, \\ E\_{\Delta} &= e^{-\alpha k^2 I} - (1 + 4F\_x \sin^2 p\_x + 4F\_y \sin^2 p\_y)^{-n}, \quad \theta = 1 \ . \end{aligned}$$

We are now in a position to compute Ei numerically. That is, we can compute the error due to iterative solution of the linear system and see if it corresponds to the convergence tolerance used in the method. Note that the convergence is based on measuring the difference in two consecutive approximations, which is not exactly the error due to the iteration, but it is a kind of measure, and it should have about the same size as Ei .

The function demo\_classic\_iterative in diffu2D\_u0.py implements the idea above (also for the methods in Sect. 3.6.12). The value of Ei is in particular printed at each time level. By changing the tolerance in the convergence criterion of the Jacobi method, we can see that Ei is of the same order of magnitude as the prescribed tolerance in the Jacobi method. For example: E <sup>10</sup><sup>2</sup> with Nx <sup>D</sup> Ny <sup>D</sup> <sup>10</sup> and - D <sup>1</sup> <sup>2</sup> , as long as max u has some significant size (max u > 0:02). An appropriate value of the tolerance is then 10<sup>3</sup>, such that the error in the Jacobi method does not become bigger than the discretization error. In that case, Ei is around <sup>5</sup> <sup>10</sup><sup>3</sup>. The corresponding number of Jacobi iterations (with ! D <sup>1</sup>) varies from 31 to 12 during the time simulation (for max u > 0:02). Changing the tolerance to 10<sup>5</sup> causes many more iterations (61 to 42) without giving any contribution to the overall accuracy, because the total error is dominated by E.

Also, with an Nx <sup>D</sup> Ny <sup>D</sup> <sup>20</sup>, the spatial accuracy increases and many more iterations are needed (143 to 45), but the dominating error is from the time discretization. However, with such a finer spatial mesh, a higher tolerance in the convergence criterion <sup>10</sup><sup>4</sup> is needed to keep Ei <sup>10</sup><sup>3</sup>. More experiments show the disadvantage of the very simple Jacobi iteration method: the number of iterations increases with the number of unknowns, keeping the tolerance fixed, but the tolerance should also be lowered to avoid the iteration error to dominate the total error. A small adjustment of the Jacobi method, as described in Sect. 3.6.12, provides a better method.

#### **3.6.11 The Relaxed Jacobi Method and Its Relation to the Forward Euler Method**

We shall now show that solving the Poisson equation ˛r2u D <sup>f</sup> by the Jacobi iterative method is in fact equivalent to using a Forward Euler scheme on ut <sup>D</sup> ˛r2u C <sup>f</sup> and letting <sup>t</sup> ! 1.

A Forward Euler discretization of the 2D diffusion equation,

$$[D\_\iota^+\iota = \alpha(D\_\iota D\_\iota \iota + D\_\jmath D\_\jmath \iota) + f]\_{i,j}^n,$$

can be written out as

$$u\_{i,j}^{n+1} = u\_{i,j}^n + \frac{\Delta t}{\alpha h^2} \left( u\_{i-1,j}^n + u\_{i+1,j}^n + u\_{i,j-1}^n + u\_{i,j+1}^n - 4u\_{i,j}^n + h^2 f\_{i,j} \right), \quad i \in \mathbb{N}$$

where <sup>h</sup> D x D y has been introduced for simplicity. The scheme can be reordered as

$$\begin{aligned} u\_{i,j}^{n+1} &= \left(1 - \omega\right) u\_{i,j}^n + \frac{1}{4}\omega \left(u\_{i-1,j}^n + u\_{i+1,j}^n + u\_{i,j-1}^n + u\_{i,j+1}^n - 4u\_{i,j}^n + h^2 f\_{i,j}\right), \\ \text{with} \end{aligned}$$

with

$$
\rho = 4 \frac{\Delta t}{\alpha h^2},
$$

but this latter form is nothing but the relaxed Jacobi method applied to

$$\left[D\_{\ge}D\_{\ge}\mu + D\_{\ge}D\_{\ge}\mu = -f\right]\_{i,j}^{n}\dots$$

From the equivalence above we know a couple of things about the Jacobi method for solving r2u D <sup>f</sup> :


These observations are quite disappointing: if we already have a time-dependent diffusion problem and want to take larger time steps by an implicit time discretization method, we will with the Jacobi method end up with something close to a slow Forward Euler simulation of the original problem at each time level. Nevertheless, the are two reasons for why the Jacobi method remains a fundamental building block for solving linear systems arising from PDEs: 1) a couple of iterations remove large parts of the error and this is effectively used in the very efficient class of multigrid methods; and 2) the idea of the Jacobi method can be developed into more efficient methods, especially the SOR method, which is treated next.

#### **3.6.12 The Gauss-Seidel and SOR Methods**

If we update the mesh points according to the Jacobi method (3.98) for a Backward Euler discretization with a loop over <sup>i</sup> <sup>D</sup> 1; : : : ; Nx <sup>1</sup> and <sup>j</sup> <sup>D</sup> 1; : : : ; Ny <sup>1</sup>, we realize that when u<sup>n</sup>C1;rC<sup>1</sup> i;j is computed, <sup>u</sup><sup>n</sup>C1;rC<sup>1</sup> <sup>i</sup>1;j and <sup>u</sup><sup>n</sup>C1;rC<sup>1</sup> i;j <sup>1</sup> are already computed, so these new values can be used rather than u<sup>n</sup>C1;r <sup>i</sup>1;j and <sup>u</sup><sup>n</sup>C1;r i;j <sup>1</sup> (respectively) in the formula for u<sup>n</sup>C1;rC<sup>1</sup> i;j . This idea gives rise to the *Gauss-Seidel* iteration method, which mathematically is just a small adjustment of (3.98):

$$\begin{aligned} u\_{i,j}^{n+1,r+1} &= \\ &\left(1 + 2F\_{\ge} + 2F\_{\ge}\right)^{-1} \left( \left( F\_{\ge} (u\_{i-1,j}^{n+1,r+1} + u\_{i+1,j}^{n+1,r}) + F\_{\ge} (u\_{i,j-1}^{n+1,r+1} + u\_{i,j+1}^{n+1,r}) \right) \right) \\ &+ u\_{i,j}^{n} + \Delta t f\_{i,j}^{n+1} \right) . \end{aligned}$$

Observe that the way we access the mesh points in the formula (3.109) is important: points with <sup>i</sup> <sup>1</sup> must be computed before points with <sup>i</sup>, and points with <sup>j</sup> <sup>1</sup> must be computed before points with j . Any sequence of mesh points can be used in the Gauss-Seidel method, but the particular math formula must distinguish between already visited points in the current iteration and the points not yet visited.

The idea of relaxation (3.101) can equally well be applied to the Gauss-Seidel method. Actually, the Gauss-Seidel method with an arbitrary 0<! <sup>2</sup> has its own name: the *Successive Over-Relaxation* method, abbreviated as SOR.

The SOR method for a rule discretization, with the shortened u and u notation, can be written

$$u\_{i,j}^{\*} = (1 + 2\theta(F\_x + F\_y))^{-1} \left(\theta(F\_x(u\_{i-1,j} + u\_{i+1,j}^-) + F\_y(u\_{i,j-1} + u\_{i,j+1}^-))\right)$$

$$\begin{split} &+ u\_{i,j}^{(1)} + \theta \Delta t f\_{i,j}^{n+1} + (1 - \theta) \Delta t f\_{i,j}^n \\ &+ (1 - \theta) \left(F\_x \left(u\_{i-1,j}^{(1)} - 2u\_{i,j}^{(1)} + u\_{i+1,j}^{(1)}\right)\right. \\ &+ F\_y \left(u\_{i,j-1}^{(1)} - 2u\_{i,j}^{(1)} + u\_{i,j+1}^{(1)}\right)\right), \quad (3.110) \\ &u\_{i,j} = au\_{i,j}^{\*} + (1 - \omega)u\_{i,j}^{-} \end{split} \tag{3.111}$$

The sequence of mesh points in (3.110) is <sup>i</sup> <sup>D</sup> 1; : : : ; Nx <sup>1</sup>, <sup>j</sup> <sup>D</sup> 1; : : : ; Ny <sup>1</sup> (but whether i runs faster or slower than j does not matter).

#### **3.6.13 Scalar Implementation of the SOR Method**

Since the Jacobi and Gauss-Seidel methods with relaxation are so similar, we can easily make a common code for the two:

```
for n in It[0:-1]:
    # Solve linear system by Jacobi/SOR iteration at time level n+1
    u_[:,:] = u_n # Start value
    converged = False
    r = 0
    while not converged:
        if version == 'scalar':
            if iteration == 'Jacobi':
                u__ = u_
            elif iteration == 'SOR':
                u__ = u
            j = 0
            for i in Ix:
                u[i,j] = U_0y(t[n+1]) # Boundary
            for j in Iy[1:-1]:
                i = 0; u[i,j] = U_0x(t[n+1]) # Boundary
                i = Nx; u[i,j] = U_Lx(t[n+1]) # Boundary
                for i in Ix[1:-1]:
                    u_new = 1.0/(1.0 + 2*theta*(Fx + Fy))*(theta*(
                        Fx*(u_[i+1,j] + u__[i-1,j]) +
                        Fy*(u_[i,j+1] + u__[i,j-1])) + \
                    u_n[i,j] + (1-theta)*(
                      Fx*(
                    u_n[i+1,j] - 2*u_n[i,j] + u_n[i-1,j]) +
                      Fy*(
                    u_n[i,j+1] - 2*u_n[i,j] + u_n[i,j-1]))\
                      + theta*dt*f(i*dx,j*dy,(n+1)*dt)+\
                    (1-theta)*dt*f(i*dx,j*dy,n*dt))
                    u[i,j] = omega*u_new + (1-omega)*u_[i,j]
                j = Ny
                for i in Ix:
                    u[i,j] = U_Ly(t[n+1]) # boundary
        r += 1
        converged = np.abs(u-u_).max() < tol or r >= max_iter
        u_[:,:] = u
    u_n, u = u, u_n # Get ready for next iteration
```
The idea here is to introduce u\_\_ to be used for already computed values (u) in the Gauss-Seidel/SOR version of the implementation, or just values from the previous iteration (u\_) in case of the Jacobi method.

#### **3.6.14 Vectorized Implementation of the SOR Method**

Vectorizing the Gauss-Seidel iteration step turns out to be non-trivial. The problem is that vectorized operations typically imply operations on arrays where the sequence in which we visit the elements does not matter. In particular, this principle makes vectorized code trivial to parallelize. However, in the Gauss-Seidel algorithm, the sequence in which we visit the elements in the arrays does matter, and it is well known that the basic method as explained above cannot be parallelized. Therefore, also vectorization will require new thinking.

The strategy for vectorizing (and parallelizing) the Gauss-Seidel method is to use a special numbering of the mesh points called red-black numbering: every other point is red or black as in a checkerboard pattern. This numbering requires Nx and Ny to be even numbers. Here is an example of a <sup>6</sup> -6 mesh:

rbrbrbr brbrbrb rbrbrbr brbrbrb rbrbrbr brbrbrb rbrbrbr

The idea now is to first update all the red points. Each formula for updating a red point involves only the black neighbors. Thereafter, we update all the black points, and at each black point, only the recently computed red points are involved.

The scalar implementation of the red-black numbered Gauss-Seidel method is really compact, since we can update values directly in u (this guarantees that we use the most recently computed values). Here is the relevant code for the Backward Euler scheme in time and without a source term:

```
# Update internal points
for sweep in 'red', 'black':
    for j in range(1, Ny, 1):
        if sweep == 'red':
            start = 1 if j % 2 == 1 else 2
        elif sweep == 'black':
            start = 2 if j % 2 == 1 else 1
        for i in range(start, Nx, 2):
        u[i,j] = 1.0/(1.0 + 2*(Fx + Fy))*(
                     Fx*(u[i+1,j] + u[i-1,j]) +
                     Fy*(u[i,j+1] + u[i,j-1]) + u_n[i,j])
```
The vectorized version must be based on slices. Looking at a typical red-black pattern, e.g.,

rbrbrbr brbrbrb rbrbrbr brbrbrb rbrbrbr brbrbrb rbrbrbr

we want to update the internal points (marking boundary points with x):

xxxxxxx xrbrbrx xbrbrbx xrbrbrx xbrbrbx xrbrbrx xxxxxxx It is impossible to make one slice that picks out all the internal red points. Instead, we need two slices. The first involves points marked with R:

xxxxxxx xRbRbRx xbrbrbx xRbRbRx xbrbrbx xRbRbRx xxxxxxx

This slice is specified as 1::2 for i and 1::2 for j, or with slice objects:

```
i = slice(1, None, 2); j = slice(1, None, 2)
```
The second slice involves the red points with R:

xxxxxxx xrbrbrx xbRbRbx xrbrbrx xbRbRbx xrbrbrx xxxxxxx

The slices are

i = slice(2, None, 2); j = slice(2, None, 2)

For the black points, the first slice involves the B points:

xxxxxxx xrBrBrx xbrbrbx xrBrBrx xbrbrbx xrBrBrx xxxxxxx

with slice objects

i = slice(2, None, 2); j = slice(1, None, 2)

The second set of black points is shown here:

xxxxxxx xrbrbrx xBrBrBx xrbrbrx xBrBrBx xrbrbrx xxxxxxx

with slice objects

i = slice(1, None, 2); j = slice(2, None, 2)

That is, we need four sets of slices. The simplest way of implementing the algorithm is to make a function with variables for the slices representing <sup>i</sup>, <sup>i</sup> <sup>1</sup>, <sup>i</sup> C <sup>1</sup>, <sup>j</sup> , <sup>j</sup> <sup>1</sup>, and <sup>j</sup> C <sup>1</sup>, here called ic ("i center"), im1 ("i minus 1", ip1 ("i plus 1"), jc, jm1, and jp1, respectively.

```
def update(u_, u_n, ic, im1, ip1, jc, jm1, jp1):
    return \
       1.0/(1.0 + 2*theta*(Fx + Fy))*(theta*(
           Fx*(u_[ip1,jc] + u_[im1,jc]) +
           Fy*(u_[ic,jp1] + u_[ic,jm1])) +\
       u_n[ic,jc] + (1-theta)*(
         Fx*(u_n[ip1,jc] - 2*u_n[ic,jc] + u_n[im1,jc]) +\
         Fy*(u_n[ic,jp1] - 2*u_n[ic,jc] + u_n[ic,jm1]))+\
         theta*dt*f_a_np1[ic,jc]+\
         (1-theta)*dt*f_a_n[ic,jc])
```
The formula returned from update is to be compared with (3.110). The relaxed Jacobi iteration can be implemented by

```
ic = jc = slice(1,-1)
im1 = jm1 = slice(0,-2)
ip1 = jp1 = slice(2,None)
u_new[ic,jc] = update(
    u_, u_n, ic, im1, ip1, jc, jm1, jp1)
u[ic,jc] = omega*u_new[ic,jc] + (1-omega)*u_[ic,jc]
```
The Gauss-Seidel (or SOR) updates need four different steps. The ic and jc slices are specified above. For each of these, we must specify the corresponding im1, ip1, jm1, and jp1 slices. The code below contains the details.

```
# Red points
ic = slice(1,-1,2)
im1 = slice(0,-2,2)
ip1 = slice(2,None,2)
jc = slice(1,-1,2)
jm1 = slice(0,-2,2)
jp1 = slice(2,None,2)
u_new[ic,jc] = update(
    u_new, u_n, ic, im1, ip1, jc, jm1, jp1)
ic = slice(2,-1,2)
im1 = slice(1,-2,2)
ip1 = slice(3,None,2)
jc = slice(2,-1,2)
jm1 = slice(1,-2,2)
jp1 = slice(3,None,2)
u_new[ic,jc] = update(
    u_new, u_n, ic, im1, ip1, jc, jm1, jp1)
# Black points
ic = slice(2,-1,2)
im1 = slice(1,-2,2)
ip1 = slice(3,None,2)
jc = slice(1,-1,2)
jm1 = slice(0,-2,2)
jp1 = slice(2,None,2)
u_new[ic,jc] = update(
    u_new, u_n, ic, im1, ip1, jc, jm1, jp1)
ic = slice(1,-1,2)
im1 = slice(0,-2,2)
ip1 = slice(2,None,2)
jc = slice(2,-1,2)
jm1 = slice(1,-2,2)
jp1 = slice(3,None,2)
u_new[ic,jc] = update(
    u_new, u_n, ic, im1, ip1, jc, jm1, jp1)
# Relax
c = slice(1,-1)
u[c,c] = omega*u_new[c,c] + (1-omega)*u_[c,c]
```
The function solver\_classic\_iterative in diffu2D\_u0.py contains a unified implementation of the relaxed Jacobi and SOR methods in scalar and vectorized versions using the techniques explained above.

#### **3.6.15 Direct Versus Iterative Methods**

**Direct methods** There are two classes of methods for solving linear systems: direct methods and iterative methods. Direct methods are based on variants of the Gaussian elimination procedure and will produce an exact solution (in exact arithmetics) in an a priori known number of steps. Iterative methods, on the other hand, produce an approximate solution, and the amount of work for reaching a given accuracy is usually not known.

The most common direct method today is to use the *LU factorization* procedure to factor the coefficient matrix A as the product of a lower-triangular matrix L (with unit diagonal terms) and an upper-triangular matrix <sup>U</sup>: <sup>A</sup> D LU. As soon as we have <sup>L</sup> and <sup>U</sup>, a system of equations LUc D <sup>b</sup> is easy to solve because of the triangular nature of <sup>L</sup> and <sup>U</sup>. We first solve Ly D <sup>b</sup> for <sup>y</sup> (forward substitution), and thereafter we find <sup>c</sup> from solving Uc D <sup>y</sup> (backward substitution). When <sup>A</sup> is a dense <sup>N</sup> - N matrix, the LU factorization costs <sup>1</sup> <sup>3</sup>N <sup>3</sup> arithmetic operations, while the forward and backward substitution steps each require of the order N <sup>2</sup> arithmetic operations. That is, factorization dominates the costs, while the substitution steps are cheap.

Symmetric, positive definite coefficient matrices often arise when discretizing PDEs. In this case, the LU factorization becomes <sup>A</sup> <sup>D</sup> LL<sup>T</sup> , and the associated algorithm is known as *Cholesky factorization*. Most linear algebra software offers highly optimized implementations of LU and Cholesky factorization as well as forward and backward substitution (scipy.linalg is the relevant Python package).

Finite difference discretizations lead to sparse coefficient matrices. An extreme case arose in Sect. 3.2.1 where A was tridiagonal. For a tridiagonal matrix, the amount of arithmetic operations in the LU and Cholesky factorization algorithms is just of the order N , not N <sup>3</sup>. Tridiagonal matrices are special cases of *banded matrices*, where the matrices contain just a set of diagonal bands. Finite difference methods on regularly numbered rectangular and box-shaped meshes give rise to such banded matrices, with 5 bands in 2D and 7 in 3D for diffusion problems. Gaussian elimination only needs to work within the bands, leading to much more efficient algorithms.

If Ai;j <sup>D</sup> <sup>0</sup> for j >i <sup>C</sup> <sup>p</sup> and j <i <sup>p</sup>, <sup>p</sup> is the *half-bandwidth* of the matrix. We have in our 2D problem <sup>p</sup> <sup>D</sup> Nx <sup>C</sup> <sup>2</sup>, while in 3D, <sup>p</sup> <sup>D</sup> .Nx <sup>C</sup> 1/.Ny <sup>C</sup> 1/ C <sup>2</sup>. The cost of Gaussian elimination is then *<sup>O</sup>*.Np2/, so with <sup>p</sup> N , we see that banded matrices are much more efficient to compute with. By reordering the unknowns in clever ways, one can reduce the work of Gaussian elimination further. Fortunately, the Python programmer has access to such algorithms through the scipy.sparse.linalg package.

Although a direct method is an exact algorithm, rounding errors may in practice accumulate and pollute the solution. The effect grows with the size of the linear system, so both for accuracy and efficiency, iterative methods are better suited than direct methods for solving really large linear systems.

**Iterative methods** The Jacobi and SOR iterative methods belong to a class of iterative methods where the idea is to solve Au D <sup>b</sup> by splitting A into two parts, <sup>A</sup> D <sup>M</sup> <sup>N</sup> , such that solving systems M u D <sup>c</sup> is easy and efficient. With the splitting, we get a system

$$Mu = Nu + b,$$

which suggests an iterative method

$$M u^{r+1} = N u^r + b, \quad r = 0, 1, 2, \dots, r$$

where <sup>u</sup><sup>r</sup>C<sup>1</sup> is a new approximation to <sup>u</sup> in the <sup>r</sup> C <sup>1</sup>-th iteration. To initiate the iteration, we need a start vector u<sup>0</sup>.

The Jacobi and SOR methods are based on splitting A into a lower tridiagonal part <sup>L</sup>, the diagonal <sup>D</sup>, and an upper tridiagonal part <sup>U</sup>, such that <sup>A</sup> D <sup>L</sup>C<sup>D</sup> CU. The Jacobi method corresponds to <sup>M</sup> D <sup>D</sup> and <sup>N</sup> D <sup>L</sup> <sup>U</sup>. The Gauss-Seidel method employs <sup>M</sup> D <sup>L</sup> C <sup>D</sup> and <sup>N</sup> D U, while the SOR method corresponds to

$$M = \frac{1}{\alpha}D + L, \quad N = \frac{1-\alpha}{\alpha}D - U\ .$$

The relaxed Jacobi method has similar expressions:

$$M = \frac{1}{\alpha}D, \quad N = \frac{1-\alpha}{\alpha}D - L - U.$$

With the matrix forms of the Jacobi and SOR methods as written above, we could in an implementation alternatively fill the matrix A with entries and call general implementations of the Jacobi or SOR methods that work on a system Au D <sup>b</sup>. However, this is almost never done since forming the matrix A requires quite some code and storing A in the computer's memory is unnecessary. It is much easier to just apply the Jacobi and SOR ideas to the finite difference stencils directly in an implementation, as we have shown in detail.

Nevertheless, the matrix formulation of the Jacobi and SOR methods have been important for analyzing their convergence behavior. One can show that the error <sup>u</sup><sup>r</sup> <sup>u</sup> fulfills <sup>u</sup><sup>r</sup> <sup>u</sup> D <sup>G</sup><sup>r</sup> .u<sup>0</sup> u/, where <sup>G</sup> D <sup>M</sup>1N and <sup>G</sup><sup>k</sup> is a matrix exponential. For the method to converge, lim<sup>r</sup>!1 jjG<sup>r</sup> jj D <sup>0</sup> is a necessary and sufficient condition. This implies that the *spectral radius* of G must be less than one. Since G is directly related to the finite difference scheme for the underlying PDE problem, one can in principle compute the spectral radius. For a given PDE problem, however, this is not a practical strategy, since it is very difficult to develop useful formulas. Analysis of model problems, usually related to the Poisson equation, reveals some trends of interest: the convergence rate of the Jacobi method goes like h2, while that of SOR with an optimal ! goes like h, where h is the spatial spacing: <sup>h</sup> D x D y. That is, the efficiency of the Jacobi method quickly deteriorates with the increasing mesh resolution, and SOR is much to be preferred (even if the optimal ! remains an open question). We refer to Chapter 4 of [16] for more information on the convergence theory. One important result is that if A is symmetric and positive definite, then SOR will converge for any 0<!<2.

The optimal ! parameter can be theoretically established for a Poisson problem as

$$\omega\_o = \frac{2}{1 + \sqrt{1 - \varrho^2}}, \quad \varrho = \frac{\cos(\pi/N\_x) + (\Delta x/\Delta y)^2 \cos(\pi/N\_y)}{1 + (\Delta x/\Delta y)^2}. \tag{3.112}$$

This formula can be used as a guide also in other problems.

The Jacobi and the SOR methods have their great advantage of being trivial to implement, so they are obviously popular of this reason. However, the slow convergence of these methods limits the popularity to fairly small linear systems (i.e., coarse meshes). As soon as the matrix size grows, one is better off with more sophisticated iterative methods like the preconditioned Conjugate gradient method, which we now turn to.

Finally, we mention that there is a variant of the SOR method, called the *Symmetric Successive Over-relaxation* method, known as SSOR, where one runs a standard SOR sweep through the mesh points and then a new sweep while visiting the points in reverse order.

#### **3.6.16 The Conjugate Gradient Method**

There is no simple intuitive derivation of the Conjugate gradient method, so we refer to the many excellent expositions in the literature for the idea of the method and how the algorithm is derived. In particular, we recommend the books [1, 2, 5, 16]. A brief overview is provided in the Wikipedia article4. Here, we just state the pros and cons of the method from a user's perspective and how we utilize it in code.

The original Conjugate gradient method is limited to linear systems Au D <sup>b</sup>, where A is a symmetric and positive definite matrix. There are, however, extensions of the method to non-symmetric matrices.

A major advantage of all conjugate gradient methods is that the matrix A is only used in matrix-vector products, so we do not need form and store A if we can provide code for computing a matrix-vector product Au. Another important feature is that the algorithm is very easy to vectorize and parallelize. The primary downside of the method is that it converges slowly unless one has an effective *preconditioner* for the system. That is, instead of solving Au D <sup>b</sup>, we try to solve <sup>M</sup>1Au <sup>D</sup> M1b in the hope that the method works better for this *preconditioned* system. The matrix M is the *preconditioner* or preconditioning matrix. Now we need to perform matrix-vector products <sup>y</sup> D <sup>M</sup>1Au, which is done in two steps: first the matrixvector product <sup>v</sup> D Au is carried out and then the system My D <sup>v</sup> must be solved. Therefore, <sup>M</sup> must be cheap to compute and systems My D <sup>v</sup> must be cheap to solve.

A perfect preconditioner is <sup>M</sup> D <sup>A</sup>, but in each iteration in the Conjugate gradient method one then has so solve a system with A as coefficient matrix! A key idea is to let M be some kind of *cheap approximation* to A. The simplest preconditioner is to set <sup>M</sup> D <sup>D</sup>, where <sup>D</sup> is the diagonal of <sup>A</sup>. This choice means running one Jacobi iteration as preconditioner. Exercise 3.8 shows that the Jacobi and SOR methods can also be viewed as preconditioners.

Constructing good preconditioners is a scientific field on its own. Here we shall treat the topic just very briefly. For a user having access to the scipy.sparse. linalg library, there are Conjugate gradient methods and preconditioners readily available:

	- **–** gmres (GMRES: Generalized minimum residual method)
	- **–** bicg (BiConjugate gradient method)
	- **–** bicgstab (Stabilized BiConjugate gradient method)

<sup>4</sup> https://en.wikipedia.org/wiki/Conjugate\_gradient\_method


The ILU preconditioner is an attractive all-round type of preconditioner that is suitable for most problems on serial computers. A more efficient preconditioner is the multigrid method, and algebraic multigrid is also an all-round choice as preconditioner. The Python package PyAMG5 offers efficient implementations of the algebraic multigrid method, to be used both as a preconditioner and as a stand-alone iterative method.

The matrix arising from implicit time discretization methods applied to the diffusion equation is symmetric and positive definite. Thus, we can use the Conjugate gradient method (cg), typically in combination with an ILU preconditioner. The code is very similar to the one we created when solving the linear system by sparse Gaussian elimination, the main difference is that we now allow for calling up the Conjugate gradient function as an alternative solver.

```
def solver_sparse(
    I, a, f, Lx, Ly, Nx, Ny, dt, T, theta=0.5,
    U_0x=0, U_0y=0, U_Lx=0, U_Ly=0, user_action=None,
    method='direct', CG_prec='ILU', CG_tol=1E-5):
    """
    Full solver for the model problem using the theta-rule
    difference approximation in time. Sparse matrix with
    dedicated Gaussian elimination algorithm (method='direct')
    or ILU preconditioned Conjugate Gradients (method='CG' with
    tolerance CG_tol and preconditioner CG_prec ('ILU' or None)).
    """
    # Set up data structures as shown before
    # Precompute sparse matrix
    ...
    A = scipy.sparse.diags(
        diagonals=[main, lower, upper, lower2, upper2],
        offsets=[0, -lower_offset, lower_offset,
                 -lower2_offset, lower2_offset],
        shape=(N, N), format='csc')
    if method == 'CG':
        if CG_prec == 'ILU':
            # Find ILU preconditioner (constant in time)
            A_ilu = scipy.sparse.linalg.spilu(A) # SuperLU defaults
            M = scipy.sparse.linalg.LinearOperator(
                shape=(N, N), matvec=A_ilu.solve)
        else:
            M = None
        CG_iter = [] # No of CG iterations at time level n
    # Time loop
    for n in It[0:-1]:
        # Compute b, vectorized version
```
<sup>5</sup> https://github.com/pyamg/pyamg

```
# Solve matrix system A*c = b
if method == 'direct':
    c = scipy.sparse.linalg.spsolve(A, b)
elif method == 'CG':
    x0 = u_n.T.reshape(N) # Start vector is u_n
    CG_iter.append(0)
    def CG_callback(c_k):
        """Trick to count the no of iterations in CG."""
        CG_iter[-1] += 1
    c, info = scipy.sparse.linalg.cg(
        A, b, x0=x0, tol=CG_tol, maxiter=N, M=M,
        callback=CG_callback)
# Fill u with vector c
# Update u_n before next step
u_n, u = u, u_n
```
The number of iterations in the Conjugate gradient method is of interest, but is unfortunately not available from the cg function. Therefore, we perform a trick: in each iteration a user function CG\_callback is called where we accumulate the number of iterations in a list CG\_iter.

#### **3.6.17 What Is the Recommended Method for Solving Linear Systems?**

There is no clear answer to this question. If you have enough memory and computing time available, direct methods such as spsolve are to be preferred since they are easy to use and finds almost an exact solution. However, in larger 2D and in 3D problems, direct methods usually run too slowly or require too much memory, so one is forced to use iterative methods. The fastest and most reliable methods are in the Conjugate Gradient family, but these require suitable preconditioners. ILU is an all-round preconditioner, but it is not suited for parallel computing. The Jacobi and SOR iterative methods are easy to implement, and popular for that reason, but run slowly. Jacobi iteration is not an option in real problems, but SOR may be.

#### **3.7 Random Walk**

Models leading to diffusion equations, see Sect. 3.8, are usually based on reasoning with *averaged* physical quantities such as concentration, temperature, and velocity. The underlying physical processes involve complicated microscopic movement of atoms and molecules, but an average of a large number of molecules is performed in a small volume before the modeling starts, and the averaged quantity inside this volume is assigned as a point value at the centroid of the volume. This means that concentration, temperature, and velocity at a space-time point represent averages around the point in a small time interval and small spatial volume.

Random walk is a principally different kind of modeling procedure compared to the reasoning behind partial differential equations. The idea in random walk is to have a large number of "particles" that undergo random movements. Averaging can then be used afterwards to compute macroscopic quantities like concentration. The "particles" and their random movement represent a very simplified microscopic behavior of molecules, much simpler and computationally much more efficient than direct molecular simulation6, yet the random walk model has been very powerful to describe a wide range of phenomena, including heat conduction, quantum mechanics, polymer chains, population genetics, neuroscience, hazard games, and pricing of financial instruments.

It can be shown that random walk, when averaged, produces models that are mathematically equivalent to diffusion equations. This is the primary reason why we treat random walk in this chapter: two very different algorithms (finite difference stencils and random walk) solve the same type of problems. The simplicity of the random walk algorithm makes it particularly attractive for solving diffusion equations on massively parallel computers. The exposition here is as simple as possible, and good thorough derivation of the models is provided by Hjorth-Jensen [7].

#### **3.7.1 Random Walk in 1D**

Imagine that we have some particles that perform random moves, either to the right or to the left. We may flip a coin to decide the movement of each particle, say head implies movement to the right and tail means movement to the left. Each move is one unit length. Physicists use the term *random walk* for this type of movement. The movement is also known as drunkard's walk7. You may try this yourself: flip the coin and make one step to the left or right, and repeat the process.

We introduce the symbol N for the number of steps in a random walk. Figure 3.16 shows four different random walks with <sup>N</sup> D <sup>200</sup>.

#### **3.7.2 Statistical Considerations**

Let Sk be the stochastic variable representing a step to the left or to the right in step number <sup>k</sup>. We have that Sk D <sup>1</sup> with probability <sup>p</sup> and Sk <sup>D</sup> <sup>1</sup> with probability <sup>q</sup> <sup>D</sup> <sup>1</sup> <sup>p</sup>. The variable Sk is known as a Bernoulli variable8. The expectation of Sk is

$$\operatorname{E}[\mathcal{S}\_k] = p \cdot (-1) + q \cdot 1 = 1 - 2p,$$

and the variance is

$$\text{Var}(S\_k) = \text{E}[S\_k^2] - \text{E}[S\_k]^2 = 1 - (1 - 2p)^2 = 4p(1 - p) \dots$$

<sup>6</sup> https://en.wikipedia.org/wiki/Molecular\_dynamics

<sup>7</sup> https://en.wikipedia.org/wiki/The\_Drunkard%27s\_Walk

<sup>8</sup> https://en.wikipedia.org/wiki/Bernoulli\_distribution

**Fig. 3.16** Ensemble of 4 random walks, each with 200 steps

The position after k steps is another stochastic variable

$$\bar{X}\_k = \sum\_{i=0}^{k-1} S\_i \dots$$

The expected position is

$$\operatorname{E}[\bar{X}\_k] = \operatorname{E}\left(\sum\_{i=0}^{k-1} S\_i\right) = \sum\_{i=0}^{k-1} \operatorname{E}[S\_i] = k(1-2p)\dots$$

All the Sk variables are independent. The variance therefore becomes

$$\text{Var}(\bar{X}\_k) = \text{Var}\left(\sum\_{i=0}^{k-1} S\_i\right) = \sum\_{i=0}^{k-1} \text{Var}(S\_i) = k 4p(1-p)\dots$$

We see that Var.XN k/ is proportional with the number of steps k. For the very important case <sup>p</sup> D <sup>q</sup> D <sup>1</sup> <sup>2</sup> , EŒXNk <sup>D</sup> <sup>0</sup> and Var.X<sup>N</sup> k/ D <sup>k</sup>.

How can we estimate EŒXNk D <sup>0</sup> and Var.X<sup>N</sup> k/ D <sup>N</sup> ? We must have many random walks of the type in Fig. 3.16. For a given <sup>k</sup>, say <sup>k</sup> D <sup>100</sup>, we find all the values of <sup>X</sup><sup>N</sup> <sup>k</sup>, name them <sup>x</sup>N0;k, <sup>x</sup>N1;k, <sup>x</sup>N2;k, and so on. The empirical estimate of <sup>E</sup>ŒXN k is the average,

$$\mathbb{E}[\bar{X}\_k] \approx \frac{1}{W} \sum\_{j=0}^{W-1} \bar{\mathbf{x}}\_{j,k},$$

while an empirical estimate of Var.XN k/ is

$$\text{Var}(\bar{X}\_k) \approx \frac{1}{W} \sum\_{j=0}^{W-1} (\bar{\mathbf{x}}\_{j,k})^2 - \left(\frac{1}{W} \sum\_{j=0}^{W-1} \bar{\mathbf{x}}\_{j,k}\right)^2 \dots$$

That is, we take the statistics for a given K across the ensemble of random walks ("vertically" in Fig. 3.16). The key quantities to record are P <sup>i</sup> <sup>x</sup>Ni;k and <sup>P</sup> i xN 2 i;k.

#### **3.7.3 Playing Around with Some Code**

**Scalar code** Python has a random module for drawing random numbers, and this module has a function uniform(a, b) for drawing a uniformly distributed random number in the interval Œa; b/. If an event happens with probability p, we can simulate this on the computer by drawing a random number r in Œ0; 1/, because then <sup>r</sup> <sup>p</sup> with probability <sup>p</sup> and r>p with probability <sup>1</sup> <sup>p</sup>:

```
import random
r = random.uniform(0, 1)
if r <= p:
    # Event happens
else:
    # Event does not happen
```
A random walk with N steps, starting at x0, where we move to the left with probability <sup>p</sup> and to the right with probability <sup>1</sup> <sup>p</sup> can now be implemented by

```
import random, numpy as np
def random_walk1D(x0, N, p):
    """1D random walk with 1 particle."""
    # Store position in step k in position[k]
    position = np.zeros(N)
    position[0] = x0
    current_pos = x0
    for k in range(N-1):
        r = random.uniform(0, 1)
        if r <= p:
            current_pos -= 1
        else:
            current_pos += 1
        position[k+1] = current_pos
    return position
```
**Vectorized code** Since N is supposed to be large and we want to repeat the process for many particles, we should speed up the code as much as possible. Vectorization is the obvious technique here: we draw all the random numbers at once with aid of numpy, and then we formulate vector operations to get rid of the loop over the steps (k). The numpy.random module has vectorized versions of the functions in Python's built-in random module. For example, numpy.random.uniform(a, b, N) returns N random numbers uniformly distributed between a (included) and b (not included).

We can then make an array of all the steps in a random walk: if the random number is less than or equal to <sup>p</sup>, the step is 1, otherwise the step is <sup>1</sup>:

```
r = np.random.uniform(0, 1, size=N)
steps = np.where(r <= p, -1, 1)
```
The value of position[k] is the sum of all steps up to step k. Such sums are often needed in vectorized algorithms and therefore available by the numpy.cumsum function:

>>> import numpy as np >>> np.cumsum(np.array([1,3,4,6])) array([ 1, 4, 8, 14])

The resulting array in this demo has elements <sup>1</sup>, <sup>1</sup> C <sup>3</sup> D <sup>4</sup>, <sup>1</sup> C <sup>3</sup> C <sup>4</sup> D <sup>8</sup>, and <sup>1</sup> C <sup>3</sup> C <sup>4</sup> C <sup>6</sup> D <sup>14</sup>.

We can now vectorize the random\_walk1D function:

```
def random_walk1D_vec(x0, N, p):
    """Vectorized version of random_walk1D."""
    # Store position in step k in position[k]
    position = np.zeros(N+1)
    position[0] = x0
    r = np.random.uniform(0, 1, size=N)
    steps = np.where(r <= p, -1, 1)
    position[1:] = x0 + np.cumsum(steps)
    return position
```
This code runs about 10 times faster than the scalar version. With a parallel numpy library, the code can also automatically take advantage of hardware for parallel computing because each of the four array operations can be trivially parallelized.

**Fixing the random sequence** During software development with random numbers it is advantageous to always generate the same sequence of random numbers, as this may help debugging processes. To fix the sequence, we set a *seed* of the random number generator to some chosen integer, e.g.,

np.random.seed(10)

Calls to random\_walk1D\_vec give positions of the particle as depicted in Fig. 3.17. The particle starts at the origin and moves with <sup>p</sup> D <sup>1</sup> <sup>2</sup> . Since the seed is the same, the plot to the left is just a magnification of the first 1000 steps in the plot to the right.

**Fig. 3.17** 1000 (*left*) and 50,000 (*right*) steps of a random walk

**Verification** When we have a scalar and a vectorized code, it is always a good idea to develop a unit test for checking that they produce the same result. A problem in the present context is that the two versions apply two different random number generators. For a test to be meaningful, we need to fix the seed and use the same generator. This means that the scalar version must either use np.random or have this as an option. An option is the most flexible choice:

```
import random
def random_walk1D(x0, N, p, random=random):
    ...
    r = random.uniform(0, 1)
```
Using random=np.random,the r variable gets computed by np.random.uniform, and the sequence of random numbers will be the same as in the vectorized version that employs the same generator (given that the seed is also the same). A proper test function may be to check that the positions in the walk are the same in the scalar and vectorized implementations:

```
def test_random_walk1D():
    # For fixed seed, check that scalar and vectorized versions
    # produce the same result
    x0 = 2; N = 4; p = 0.6
    np.random.seed(10)
    scalar_computed = random_walk1D(x0, N, p, random=np.random)
    np.random.seed(10)
    vectorized_computed = random_walk1D_vec(x0, N, p)
    assert (scalar_computed == vectorized_computed).all()
```
Note that we employ == for arrays with real numbers, which is normally an inadequate test due to rounding errors, but in the present case, all arithmetics consists of adding or subtracting one, so these operations are expected to have no rounding errors. Comparing two numpy arrays with == results in a boolean array, so we need to call the all() method to ensure that all elements are True, i.e., that all elements in the two arrays match each other pairwise.

#### **3.7.4 Equivalence with Diffusion**

The original random walk algorithm can be said to work with dimensionless coordinates <sup>x</sup>N<sup>i</sup> D <sup>N</sup> <sup>C</sup> <sup>i</sup>, <sup>i</sup> <sup>D</sup> 0; 1; : : : ; 2N <sup>C</sup> <sup>1</sup> (<sup>i</sup> <sup>2</sup> <sup>Œ</sup>N; N), and <sup>t</sup> N <sup>n</sup> <sup>D</sup> <sup>n</sup>, <sup>n</sup> D 0; 1; : : : ; N . A mesh with spacings x and t with dimensions can be introduced by

$$\mathbf{x}\_{i} = X\_{0} + \bar{\mathbf{x}}\_{i} \Delta \mathbf{x}, \quad t\_{n} = \bar{t}\_{n} \Delta t \ .$$

If we implement the algorithm with dimensionless coordinates, we can just use this rescaling to obtain the movement in a coordinate system without unit spacings.

Let P<sup>n</sup>C<sup>1</sup> <sup>i</sup> be the probability of finding the particle at mesh point <sup>x</sup>N<sup>i</sup> at time <sup>t</sup> N <sup>n</sup>C<sup>1</sup>. We can reach mesh point .i; n C 1/ in two ways: either coming in from the left from .i 1; n/ or from the right (<sup>i</sup> C 1; n/. Each has probability <sup>1</sup> <sup>2</sup> (if we assume <sup>p</sup> D <sup>q</sup> D <sup>1</sup> <sup>2</sup> ). The fundamental equation for <sup>P</sup><sup>n</sup>C<sup>1</sup> <sup>i</sup> is

$$P\_i^{n+1} = \frac{1}{2} P\_{i-1}^n + \frac{1}{2} P\_{i+1}^n \,. \tag{3.113}$$

(This equation is easiest to understand if one looks at the random walk as a Markov process and applies the transition probabilities, but this is beyond scope of the present text.)

Subtracting P<sup>n</sup> <sup>i</sup> from (3.113) results in

$$P\_i^{n+1} - P\_i^n = \frac{1}{2} \left( P\_{i-1}^n - 2P\_i^n + \frac{1}{2}P\_{i+1}^n \right) \dots$$

Readers who have seen the Forward Euler discretization of a 1D diffusion equation recognize this scheme as very close to such a discretization. We have

$$\frac{\partial}{\partial t}P(\mathbf{x}\_{i},t\_{n}) = \frac{P\_{i}^{n+1} - P\_{i}^{n}}{\Delta t} + \mathcal{O}(\Delta t),$$

or in dimensionless coordinates

$$\frac{\partial}{\partial \bar{t}} P(\bar{\boldsymbol{x}}\_i, \bar{t}\_n) \approx P\_i^{n+1} - P\_i^n \dots$$

Similarly, we have

$$\begin{aligned} \frac{\partial^2}{\partial \boldsymbol{x}^2} P(\mathbf{x}\_i, t\_n) &= \frac{P\_{i-1}^n - 2P\_i^n + \frac{1}{2}P\_{i+1}^n}{\Delta \boldsymbol{x}^2} + \mathcal{O}(\Delta \boldsymbol{x}^2),\\ \frac{\partial^2}{\partial \boldsymbol{x}^2} P(\bar{\boldsymbol{x}}\_i, \bar{t}\_n) &\approx P\_{i-1}^n - 2P\_i^n + \frac{1}{2}P\_{i+1}^n. \end{aligned}$$

Equation (3.113) is therefore equivalent with the dimensionless diffusion equation

$$\frac{\partial P}{\partial \bar{t}} = \frac{1}{2} \frac{\partial^2 P}{\partial \bar{x}^2},\tag{3.114}$$

or the diffusion equation

$$\frac{\partial P}{\partial t} = D \frac{\partial^2 P}{\partial \mathbf{x}^2},\tag{3.115}$$

with diffusion coefficient

$$D = \frac{\Delta x^2}{2\Delta t}.$$

This derivation shows the tight link between random walk and diffusion. If we keep track of where the particle is, and repeat the process many times, or run the algorithms for lots of particles, the histogram of the positions will approximate the solution of the diffusion equation for the local probability P<sup>n</sup> i .

Suppose all the random walks start at the origin. Then the initial condition for the probability distribution is the Dirac delta function ı.x/. The solution of (3.114) can be shown to be

$$
\vec{P}(\bar{\mathbf{x}},\bar{t}) = \frac{1}{\sqrt{4\pi\alpha t}}e^{-\frac{\mathbf{x}^2}{4\alpha t}},\tag{3.116}
$$

where ˛ D <sup>1</sup> 2 .

#### **3.7.5 Implementation of Multiple Walks**

Our next task is to implement an ensemble of walks (for statistics, see Sect. 3.7.2) and also provide data from the walks such that we can compute the probabilities of the positions as introduced in the previous section. An appropriate representation of probabilities P<sup>n</sup> <sup>i</sup> are histograms (with i along the x axis) for a few selected values of n.

To estimate the expectation and variance of the random walks, Sect. 3.7.2 points to recording P <sup>j</sup> xj;k and P <sup>j</sup> x<sup>2</sup> j;k, where xj;k is the position at time/step level k in random walk number j . The histogram of positions needs the individual values xi;k for all i values and some selected k values.

We introduce position[k] to hold P <sup>j</sup> xj;k, position2[k] to hold P <sup>j</sup> .xj;k/2, and pos\_hist[i,k] to hold xi;k. A selection of k values can be specified by saying how many, num\_times, and let them be equally spaced through time:

pos\_hist\_times = [(N//num\_times)\*i for i in range(num\_times)]

This is one of the few situations where we want integer division (//) or real division rounded to an integer.

**Scalar version** Our scalar implementation of running num\_walks random walks may go like this:

```
def random_walks1D(x0, N, p, num_walks=1, num_times=1,
                   random=random):
    """Simulate num_walks random walks from x0 with N steps."""
    position = np.zeros(N+1) # Accumulated positions
    position[0] = x0*num_walks
    position2 = np.zeros(N+1) # Accumulated positions**2
    position2[0] = x0**2*num_walks
    # Histogram at num_times selected time points
    pos_hist = np.zeros((num_walks, num_times))
    pos_hist_times = [(N//num_times)*i for i in range(num_times)]
    #print 'save hist:', post_hist_times
```

```
for n in range(num_walks):
    num_times_counter = 0
    current_pos = x0
    for k in range(N):
        if k in pos_hist_times:
            #print 'save, k:', k, num_times_counter, n
            pos_hist[n,num_times_counter] = current_pos
            num_times_counter += 1
        # current_pos corresponds to step k+1
        r = random.uniform(0, 1)
        if r <= p:
            current_pos -= 1
        else:
            current_pos += 1
        position [k+1] += current_pos
        position2[k+1] += current_pos**2
return position, position2, pos_hist, np.array(pos_hist_times)
```
**Vectorized version** We have already vectorized a single random walk. The additional challenge here is to vectorize the computation of the data for the histogram, pos\_hist, but given the selected steps in pos\_hist\_times, we can find the corresponding positions by indexing with the list pos\_hist\_times: position[post\_hist\_times], which are to be inserted in pos\_hist[n,:].

```
def random_walks1D_vec1(x0, N, p, num_walks=1, num_times=1):
   """Vectorized version of random_walks1D."""
   position = np.zeros(N+1) # Accumulated positions
   position2 = np.zeros(N+1) # Accumulated positions**2
   walk = np.zeros(N+1) # Positions of current walk
   walk[0] = x0
   # Histogram at num_times selected time points
   pos_hist = np.zeros((num_walks, num_times))
   pos_hist_times = [(N//num_times)*i for i in range(num_times)]
   for n in range(num_walks):
       r = np.random.uniform(0, 1, size=N)
       steps = np.where(r <= p, -1, 1)
       walk[1:] = x0 + np.cumsum(steps) # Positions of this walk
       position += walk
       position2 += walk**2
       pos_hist[n,:] = walk[pos_hist_times]
   return position, position2, pos_hist, np.array(pos_hist_times)
```
**Improved vectorized version** Looking at the vectorized version above, we still have one potentially long Python loop over n. Normally, num\_walks will be much larger than N. The vectorization of the loop over N certainly speeds up the program, but if we think of vectorization as also a way to parallelize the code, all the independent walks (the n loop) can be executed in parallel. Therefore, we should include this loop as well in the vectorized expressions, at the expense of using more memory.

We introduce the array walks to hold the <sup>N</sup> C <sup>1</sup> steps of all the walks: each row represents the steps in one walk.

```
walks = np.zeros((num_walks, N+1)) # Positions of each walk
walks[:,0] = x0
```
Since all the steps are independent, we can just make one long vector of enough random numbers (N\*num\_walks), translate these numbers to ˙1, then we reshape the array such that the steps of each walk are stored in the rows.

```
r = np.random.uniform(0, 1, size=N*num_walks)
steps = np.where(r <= p, -1, 1).reshape(num_walks, N)
```
The next step is to sum up the steps in each walk. We need the np.cumsum function for this, with the argument axis=1 for indicating a sum across the columns:

```
>>> a = np.arange(6).reshape(2,3)
>>> a
array([[0, 1, 2],
       [3, 4, 5]])
>>> np.cumsum(a, axis=1)
array([[ 0, 1, 3],
       [ 3, 7, 12]])
```
Now walks can be computed by

walks[:,1:] = x0 + np.cumsum(steps, axis=1)

The position vector is the sum of all the walks. That is, we want to sum all the rows, obtained by

position = np.sum(walks, axis=0)

A corresponding expression computes the squares of the positions. Finally, we need to compute pos\_hist, but that is a matter of grabbing some of the walks (according to pos\_hist\_times):

```
pos_hist[:,:] = walks[:,pos_hist_times]
```
The complete vectorized algorithm without any loop can now be summarized:

```
def random_walks1D_vec2(x0, N, p, num_walks=1, num_times=1):
   """Vectorized version of random_walks1D; no loops."""
   position = np.zeros(N+1) # Accumulated positions
   position2 = np.zeros(N+1) # Accumulated positions**2
   walks = np.zeros((num_walks, N+1)) # Positions of each walk
   walks[:,0] = x0
   # Histogram at num_times selected time points
   pos_hist = np.zeros((num_walks, num_times))
   pos_hist_times = [(N//num_times)*i for i in range(num_times)]
```

```
r = np.random.uniform(0, 1, size=N*num_walks)
steps = np.where(r <= p, -1, 1).reshape(num_walks, N)
walks[:,1:] = x0 + np.cumsum(steps, axis=1)
position = np.sum(walks, axis=0)
position2 = np.sum(walks**2, axis=0)
pos_hist[:,:] = walks[:,pos_hist_times]
return position, position2, pos_hist, np.array(pos_hist_times)
```
What is the gain of the vectorized implementations? One important gain is that each vectorized operation can be automatically parallelized if one applies a parallel numpy library like Numba9. On a single CPU, however, the speed up of the vectorized operations is also significant. With <sup>N</sup> D <sup>1000</sup> and 50,000 repeated walks, the two vectorized versions run about 25 and 18 times faster than the scalar version, with random\_walks1D\_vec1 being fastest.

**Remark on vectorized code and parallelization** Our first attempt on vectorization removed the loop over the N steps in a single walk. However, the number of walks is usually much larger than N , because of the need for accurate statistics. Therefore, we should rather remove the loop over all walks. It turns out, from our efficiency experiments, that the function random\_walks1D\_vec2 (with no loops) is slower than random\_walks1D\_vec1. This is a bit surprising and may be explained by less efficiency in the statements involving very large arrays, containing all steps for all walks at once.

From a parallelization and improved vectorization point of view, it would be more natural to switch the sequence of the loops in the serial code such that the shortest loop is the outer loop:

```
def random_walks1D2(x0, N, p, num_walks=1, num_times=1, ...):
    ...
    current_pos = x0 + np.zeros(num_walks)
    num_times_counter = -1
    for k in range(N):
        if k in pos_hist_times:
        num_times_counter += 1
        store_hist = True
    else:
        store_hist = False
        for n in range(num_walks):
            # current_pos corresponds to step k+1
            r = random.uniform(0, 1)
        if r <= p:
                current_pos[n] -= 1
            else:
                current_pos[n] += 1
            position [k+1] += current_pos[n]
            position2[k+1] += current_pos[n]**2
            if store_hist:
                pos_hist[n,num_times_counter] = current_pos[n]
    return position, position2, pos_hist, np.array(pos_hist_times)
```
<sup>9</sup> http://numba.pydata.org

The vectorized version of this code, where we just vectorize the loop over n, becomes

```
def random_walks1D2_vec1(x0, N, p, num_walks=1, num_times=1):
    """Vectorized version of random_walks1D2."""
    position = np.zeros(N+1) # Accumulated positions
    position2 = np.zeros(N+1) # Accumulated positions**2
    # Histogram at num_times selected time points
    pos_hist = np.zeros((num_walks, num_times))
    pos_hist_times = [(N//num_times)*i for i in range(num_times)]
    current_pos = np.zeros(num_walks)
    current_pos[0] = x0
    num_times_counter = -1
    for k in range(N):
        if k in pos_hist_times:
        num_times_counter += 1
        store_hist = True # Store histogram data for this k
    else:
        store_hist = False
        # Move all walks one step
        r = np.random.uniform(0, 1, size=num_walks)
        steps = np.where(r <= p, -1, 1)
        current_pos += steps
        position[k+1] = np.sum(current_pos)
        position2[k+1] = np.sum(current_pos**2)
        if store_hist:
            pos_hist[:,num_times_counter] = current_pos
    return position, position2, pos_hist, np.array(pos_hist_times)
```
This function runs significantly faster than the random\_walks1D\_vec1 function above, typically 1.7 times faster. The code is also more appropriate in a parallel computing context since each vectorized statement can work with data of size num\_walks over the compute units, repeated N times (compared with data of size N, repeated num\_walks times, in random\_walks1D\_vec1).

The scalar code with switched loops, random\_walks1D2 runs a bit slower than the original code in random\_walks1D, so with the longest loop as the inner loop, the vectorized function random\_walks1D2\_vec1 is almost 60 times faster than the scalar counterpart, while the code random\_walks1D\_vec2 without loops is only around 18 times faster. Taking into account the very large arrays required by the latter function, we end up with random\_walks1D2\_vec1 as the preferred implementation.

**Test function** During program development, it is highly recommended to carry out computations by hand for, e.g., N=4 and num\_walks=3. Normally, this is done by executing the program with these parameters and checking with pen and paper that the computations make sense. The next step is to use this test for correctness in a formal test function.

First, we need to check that the simulation of multiple random walks reproduces the results of random\_walk1D,random\_walk1D\_vec1, and random\_walk1D\_vec2 for the first walk, if the seed is the same. Second, we run three random walks (N=4) with the scalar and the two vectorized versions and check that the returned arrays are identical.

For this type of test to be successful, we must be sure that exactly the same set of random numbers are used in the three versions, a fact that requires the same random number generator and the same seed, of course, but also the same sequence of computations. This is not obviously the case with the three random\_walk1D\* functions we have presented. The critical issue in random\_walk1D\_vec1 is that the first random numbers are used for the first walk, the second set of random numbers is used for the second walk and so on, to be compatible with how the random numbers are used in the function random\_walk1D. For the function random\_walk1D\_vec2 the situation is a bit more complicated since we generate all the random numbers at once. However, the critical step now is the reshaping of the array returned from np.where: we must reshape as (num\_walks, N) to ensure that the first N random numbers are used for the first walk, the next N numbers are used for the second walk, and so on.

We arrive at the test function below.

```
def test_random_walks1D():
    # For fixed seed, check that scalar and vectorized versions
    # produce the same result
    x0 = 0; N = 4; p = 0.5
    # First, check that random_walks1D for 1 walk reproduces
    # the walk in random_walk1D
    num_walks = 1
    np.random.seed(10)
    computed = random_walks1D(
        x0, N, p, num_walks, random=np.random)
    np.random.seed(10)
    expected = random_walk1D(
        x0, N, p, random=np.random)
    assert (computed[0] == expected).all()
    # Same for vectorized versions
    np.random.seed(10)
    computed = random_walks1D_vec1(x0, N, p, num_walks)
    np.random.seed(10)
    expected = random_walk1D_vec(x0, N, p)
    assert (computed[0] == expected).all()
    np.random.seed(10)
    computed = random_walks1D_vec2(x0, N, p, num_walks)
    np.random.seed(10)
    expected = random_walk1D_vec(x0, N, p)
    assert (computed[0] == expected).all()
    # Second, check multiple walks: scalar == vectorized
    num_walks = 3
    num_times = N
    np.random.seed(10)
    serial_computed = random_walks1D(
        x0, N, p, num_walks, num_times, random=np.random)
```

```
np.random.seed(10)
vectorized1_computed = random_walks1D_vec1(
    x0, N, p, num_walks, num_times)
np.random.seed(10)
vectorized2_computed = random_walks1D_vec2(
    x0, N, p, num_walks, num_times)
# positions: [0, 1, 0, 1, 2]
# Can test without tolerance since everything is +/- 1
return_values = ['pos', 'pos2', 'pos_hist', 'pos_hist_times']
for s, v, r in zip(serial_computed,
                   vectorized1_computed,
                   return_values):
    msg = '%s: %s (serial) vs %s (vectorized)' % (r, s, v)
    assert (s == v).all(), msg
for s, v, r in zip(serial_computed,
                   vectorized2_computed,
                   return_values):
    msg = '%s: %s (serial) vs %s (vectorized)' % (r, s, v)
    assert (s == v).all(), msg
```
Such test functions are indispensable for further development of the code as we can at any time test whether the basic computations remain correct or not. This is particularly important in stochastic simulations since without test functions and fixed seeds, we always experience variations from run to run, and it can be very difficult to spot bugs through averaged statistical quantities.

#### **3.7.6 Demonstration of Multiple Walks**

Assuming now that the code works, we can just scale up the number of steps in each walk and the number of walks. The latter influences the accuracy of the statistical estimates. Figure 3.18 shows the impact of the number of walks on the expectation, which should approach zero. Figure 3.19 displays the corresponding estimate of the variance of the position, which should grow linearly with the number of steps. It does, seemingly very accurately, but notice that the scale on the y axis is so much larger than for the expectation, so irregularities due to the stochastic nature of the process become so much less visible in the variance plots. The probability of finding a particle at a certain position at time (or step) 800 is shown in Fig. 3.20. The dashed red line is the theoretical distribution (3.116) arising from solving the diffusion equation (3.114) instead. As always, we realize that one needs significantly more statistical samples to estimate a histogram accurately than the expectation or variance.

#### **3.7.7 Ascii Visualization of 1D Random Walk**

If we want to study (very) long time series of random walks, it can be convenient to plot the position in a terminal window with the time axis pointing downwards. The module avplotter in SciTools has a class Plotter for plotting functions in the terminal window with the aid of ascii symbols only. Below is the code required to visualize a simple random walk, starting at the origin, and considered over when

**Fig. 3.18** Estimated expected value for 1000 steps, using 100 walks (*upper left*), 10,000 (*upper right*), 100,000 (*lower left*), and 1,000,000 (*lower right*)

the point <sup>x</sup> D <sup>1</sup> is reached. We use a spacing x D 0:05 (so <sup>x</sup> D <sup>1</sup> corresponds to <sup>i</sup> D 20).

```
def run_random_walk():
   from scitools.avplotter import Plotter
   import time, numpy as np
   p = Plotter(-1, 1, width=75) # Horizontal axis: 75 chars wide
   dx = 0.05
   np.random.seed(10)
   x = 0
   while True:
       random_step = 1 if np.random.random() > 0.5 else -1
       x = x + dx*random_step
       if x < -1:
           break # Destination reached!
       print p.plot(0, x)
       # Allow Ctrl+c to abort the simulation
       try:
           time.sleep(0.1) # Wait for interrupt
       except KeyboardInterrupt:
           print 'Interrupted by Ctrl+c'
           break
```
**Fig. 3.19** Estimated variance over 1000 steps, using 100 walks (*upper left*), 10,000 (*upper right*), 100,000 (*lower left*), and 1,000,000 (*lower right*)

Observe that we implement an infinite loop, but allow a smooth interrupt of the program by Ctrl+c through Python's KeyboardInterrupt exception. This is a useful recipe that can be used in many occasions!

The output looks typically like

**Fig. 3.20** Estimated probability distribution at step 800, using 100 walks (*upper left*), 10,000 (*upper right*), 100,000 (*lower left*), and 1,000,000 (*lower right*)

Positions beyond the limits of the x axis appear with a value. A long file10 contains the complete ascii plot corresponding to the function run\_random\_walk above.

#### **3.7.8 Random Walk as a Stochastic Equation**

The (dimensionless) position in a random walk, <sup>X</sup><sup>N</sup> <sup>k</sup>, can be expressed as a stochastic difference equation:

$$
\bar{X}\_k = \bar{X}\_{k-1} + s, \quad \varkappa\_0 = 0,\tag{3.117}
$$

where <sup>s</sup> is a Bernoulli variable11, taking on the two values <sup>s</sup> D <sup>1</sup> and <sup>s</sup> <sup>D</sup> <sup>1</sup> with equal probability:

$$\mathbf{P}(\mathbf{s}=\mathbf{l})=\frac{1}{2}, \quad \mathbf{P}(\mathbf{s}=-\mathbf{l})=\frac{1}{2}\dots$$

The s variable in a step is independent of the s variable in other steps.

The difference equation expresses essentially the sum of independent Bernoulli variables. Because of the central limit theorem, Xk, will then be normally distributed with expectation kEŒs and kVar.s/. The expectation and variance of a Bernoulli variable with values <sup>r</sup> D <sup>0</sup> and <sup>r</sup> D <sup>1</sup> are <sup>p</sup> and p.1 p/, respectively.

<sup>10</sup> http://bit.ly/1UbULeH

<sup>11</sup> https://en.wikipedia.org/wiki/Bernoulli\_distribution

The variable <sup>s</sup> D 2r <sup>1</sup> then has expectation <sup>2</sup>EŒr <sup>1</sup> D 2p <sup>1</sup> D <sup>0</sup> and variance <sup>2</sup><sup>2</sup>Var.r / <sup>D</sup> 4p.1 p/ <sup>D</sup> <sup>1</sup>. The position Xk is normally distributed with zero expectation and variance k, as we found in Sect. 3.7.2.

The central limit theorem tells that as long as k is not small, the distribution of Xk remains the same if we replace the Bernoulli variable s by any other stochastic variable with the same expectation and variance. In particular, we may let s be a standardized Gaussian variable (zero mean, unit variance).

Dividing (3.117) by t gives

$$\frac{X\_k - X\_{k-1}}{\Delta t} = \frac{1}{\Delta t} s \dots$$

In the limit t ! <sup>0</sup>, s=t approaches a white noise stochastic process. With X .t / <sup>N</sup> as the continuous process in the limit t ! <sup>0</sup> (Xk ! X.tk/), we formally get the stochastic differential equation

$$dX = dW,\tag{3.118}$$

where W .t/ is a Wiener process12. Then X is also a Wiener process. It follows from the stochastic ODE dX D dW that the probability distribution of <sup>X</sup> is given by the Fokker-Planck equation<sup>13</sup> (3.114). In other words, the key results for random walk we found earlier can alternatively be derived via a stochastic ordinary differential equation and its related Fokker-Planck equation.

#### **3.7.9 Random Walk in 2D**

The most obvious generalization of 1D random walk to two spatial dimensions is to allow movements to the north, east, south, and west, with equal probability <sup>1</sup> 4 .

```
def random_walk2D(x0, N, p, random=random):
    """2D random walk with 1 particle and N moves: N, E, W, S."""
    # Store position in step k in position[k]
    d = len(x0)
    position = np.zeros((N+1, d))
    position[0,:] = x0
    current_pos = np.array(x0, dtype=float)
    for k in range(N):
        r = random.uniform(0, 1)
        if r <= 0.25:
            current_pos += np.array([0, 1]) # Move north
        elif 0.25 < r <= 0.5:
            current_pos += np.array([1, 0]) # Move east
        elif 0.5 < r <= 0.75:
            current_pos += np.array([0, -1]) # Move south
        else:
            current_pos += np.array([-1, 0]) # Move west
        position[k+1,:] = current_pos
    return position
```
<sup>12</sup> https://en.wikipedia.org/wiki/Wiener\_process

<sup>13</sup> https://en.wikipedia.org/wiki/Fokker-Planck\_equation

**Fig. 3.21** Random walks in 2D with 200 steps: rectangular mesh (*left*) and diagonal mesh (*right*)

The left plot in Fig. 3.21 provides an example on 200 steps with this kind of walk. We may refer to this walk as a walk on a *rectangular mesh* as we move from any spatial mesh point .i; j / to one of its four neighbors in the rectangular directions: .i C 1; j /, .i 1; j /, .i; j C 1/, or .i; j 1/.

#### **3.7.10 Random Walk in Any Number of Space Dimensions**

From a programming point of view, especially when implementing a random walk in any number of dimensions, it is more natural to consider a walk in the diagonal directions NW, NE, SW, and SE. On a two-dimensional spatial mesh it means that we go from .i; j /to either .i C1; j C1/, .i 1; j C1/, .i C1; j 1/, or .i 1; j 1/. We can with such a *diagonal mesh* (see right plot in Fig. 3.21) draw a Bernoulli variable for the step in each spatial direction and trivially write code that works in any number of spatial directions:

```
def random_walkdD(x0, N, p, random=random):
    """Any-D (diagonal) random walk with 1 particle and N moves."""
    # Store position in step k in position[k]
    d = len(x0)
    position = np.zeros((N+1, d))
    position[0,:] = x0
    current_pos = np.array(x0, dtype=float)
    for k in range(N):
        for i in range(d):
            r = random.uniform(0, 1)
            if r <= p:
                current_pos[i] -= 1
            else:
                current_pos[i] += 1
        position[k+1,:] = current_pos
    return position
```
A vectorized version is desired. We follow the ideas from Sect. 3.7.3, but each step is now a vector in d spatial dimensions. We therefore need to draw Nd random numbers in r, compute steps in the various directions through np.where(r <=p,

**Fig. 3.22** Four random walks with 5000 steps in 2D


```
>>> a = np.arange(6).reshape(3,2)
>>> a
array([[0, 1],
       [2, 3],
       [4, 5]])
>>> np.cumsum(a, axis=0)
array([[ 0, 1],
       [ 2, 4],
       [ 6, 9]])
```
With such summation of step vectors, we get all the positions to be filled in the position array:

```
def random_walkdD_vec(x0, N, p):
    """Vectorized version of random_walkdD."""
    d = len(x0)
    # Store position in step k in position[k]
    position = np.zeros((N+1,d))
    position[0] = np.array(x0, dtype=float)
    r = np.random.uniform(0, 1, size=N*d)
    steps = np.where(r <= p, -1, 1).reshape(N,d)
    position[1:,:] = x0 + np.cumsum(steps, axis=0)
    return position
```
#### **3.7.11 Multiple Random Walks in Any Number of Space Dimensions**

As we did in 1D, we extend one single walk to a number of walks (num\_walks in the code).

**Scalar code** As always, we start with implementing the scalar case:

```
def random_walksdD(x0, N, p, num_walks=1, num_times=1,
                   random=random):
    """Simulate num_walks random walks from x0 with N steps."""
    d = len(x0)
    position = np.zeros((N+1, d)) # Accumulated positions
    position2 = np.zeros((N+1, d)) # Accumulated positions**2
    # Histogram at num_times selected time points
    pos_hist = np.zeros((num_walks, num_times, d))
    pos_hist_times = [(N//num_times)*i for i in range(num_times)]
    for n in range(num_walks):
        num_times_counter = 0
        current_pos = np.array(x0, dtype=float)
        for k in range(N):
            if k in pos_hist_times:
                pos_hist[n,num_times_counter,:] = current_pos
                num_times_counter += 1
            # current_pos corresponds to step k+1
            for i in range(d):
                r = random.uniform(0, 1)
                if r <= p:
                    current_pos[i] -= 1
                else:
                    current_pos[i] += 1
            position [k+1,:] += current_pos
            position2[k+1,:] += current_pos**2
    return position, position2, pos_hist, np.array(pos_hist_times)
```
**Vectorized code** Significant speed-ups can be obtained by vectorization. We get rid of the loops in the previous function and arrive at the following vectorized code.

```
def random_walksdD_vec(x0, N, p, num_walks=1, num_times=1):
    """Vectorized version of random_walks1D; no loops."""
    d = len(x0)
    position = np.zeros((N+1, d)) # Accumulated positions
    position2 = np.zeros((N+1, d)) # Accumulated positions**2
    walks = np.zeros((num_walks, N+1, d)) # Positions of each walk
    walks[:,0,:] = x0
    # Histogram at num_times selected time points
    pos_hist = np.zeros((num_walks, num_times, d))
    pos_hist_times = [(N//num_times)*i for i in range(num_times)]
    r = np.random.uniform(0, 1, size=N*num_walks*d)
    steps = np.where(r <= p, -1, 1).reshape(num_walks, N, d)
    walks[:,1:,:] = x0 + np.cumsum(steps, axis=1)
    position = np.sum(walks, axis=0)
    position2 = np.sum(walks**2, axis=0)
    pos_hist[:,:,:] = walks[:,pos_hist_times,:]
    return position, position2, pos_hist, np.array(pos_hist_times)
```
#### **3.8 Applications**

#### **3.8.1 Diffusion of a Substance**

The first process to be considered is a substance that gets transported through a fluid at rest by pure diffusion. We consider an arbitrary volume V of this fluid, containing the substance with concentration function c.x;t/. Physically, we can think of a very small volume with centroid x at time <sup>t</sup> and assign the ratio of the volume of the substance and the total volume to c.x;t/. This means that the mass of the substance in a small volume V is approximately %cV , where % is the density of the substance. Consequently, the total mass of the substance inside the volume V is the sum of all %cV , which becomes the volume integral R <sup>V</sup> %cdV .

Let us reason how the mass of the substance changes and thereby derive a PDE governing the concentration <sup>c</sup>. Suppose the substance flows out of <sup>V</sup> with a flux q. If S is a small part of the boundary @V of V , the volume of the substance flowing out through dS in a small time interval t is %q ntS, where n is an outward unit normal to the boundary @V , see Fig. 3.23. We realize that only the normal component of q is able to transport mass in and out of <sup>V</sup> . The total outflow of the mass of the substance in a small time interval t becomes the surface integral

$$\int\_{\partial V} \varrho q \cdot \mathfrak{n} \Delta t \, dS \, .$$

Assuming conservation of mass, this outflow of mass must be balanced by a loss of mass inside the volume. The increase of mass inside the volume, during a small time interval t, is

$$\int\_{V} \varrho(c(\mathbf{x}, t + \Delta t) - c(\mathbf{x}, t))dV,$$

assuming % is constant, which is reasonable. The outflow of mass balances the loss of mass in V , which is the increase with a minus sign. Setting the two contributions equal to each other ensures balance of mass inside V . Dividing by t gives

$$\int\_{V} \varrho \frac{c(\mathbf{x}, t + \Delta t) - c(\mathbf{x}, t)}{\Delta t} dV = -\int\_{\partial V} \varrho \mathbf{q} \cdot \mathbf{n} \, dS \, \mathcal{A}$$

Note the minus sign on the right-hand side: the left-hand side expresses loss of mass, while the integral on the right-hand side is the gain of mass.

**Fig. 3.23** An arbitrary volume of a fluid

Now, letting t ! <sup>0</sup>, we have

$$\frac{c(\mathbf{x}, t + \Delta t) - c(\mathbf{x}, t)}{\Delta t} \to \frac{\partial c}{\partial t},$$

so

$$\int\_{V} \varrho \frac{\partial c}{\partial t} dV + \int\_{\partial V} \varrho q \cdot \mathfrak{n} \, dS = 0 \,. \tag{3.119}$$

To arrive at a PDE, we express the surface integral as a volume integral using Gauss' divergence theorem:

$$\int\_{V} \left( \varrho \frac{\partial c}{\partial t} + \nabla \cdot (\varrho q) \right) dV = 0 \,,$$

Since % is constant, we can divide by this quantity. If the integral is to vanish for an arbitrary volume V , the integrand must vanish too, and we get the mass conservation PDE for the substance:

$$\frac{\partial c}{\partial t} + \nabla \cdot \mathbf{q} = 0.\tag{3.120}$$

A fundamental problem is that this is a scalar PDE for four unknowns: c and the three components of q. We therefore need additional equations. Here, Fick's law comes at rescue: it models how the flux q of the substance is related to the concentration c. Diffusion is recognized by mass flowing from regions with high concentration to regions of low concentration. This principle suggests that q is proportional to the negative gradient of c:

$$
\boldsymbol{q} = -\boldsymbol{\alpha} \nabla \boldsymbol{c}, \tag{3.121}
$$

where ˛ is an empirically determined constant. The relation (3.121) is known as Fick's law. Inserting (3.121) in (3.120) gives a scalar PDE for the concentration c:

$$\frac{\partial c}{\partial t} = \alpha \nabla^2 c \,. \tag{3.122}$$

#### **3.8.2 Heat Conduction**

Heat conduction is a well-known diffusion process. The governing PDE is in this case based on the first law of thermodynamics: the increase in energy of a system is equal to the work done on the system, plus the supplied heat. Here, we shall consider media at rest and neglect work done on the system. The principle then reduces to a balance between increase in internal energy and supplied heat flow by conduction.

Let e.x; t/ be the *internal energy* per unit mass. The increase of the internal energy in a small volume V in a small time interval t is then

$$
\varrho(e(\mathbf{x}, t + \Delta t) - e(\mathbf{x}, t))\Delta V,
$$

where % is the density of the material subject to heat conduction. In an arbitrary volume V , as depicted in Fig. 3.23, the corresponding increase in internal energy becomes the volume integral

$$\int\_{V} \varrho(e(\mathbf{x}, t + \Delta t) - e(\mathbf{x}, t))dV \,.$$

This increase in internal energy is balanced by heat supplied by conduction. Let q be the heat flow per time unit. Through the surface @V of V the following amount of heat flows out of V during a time interval t:

$$\int\_{\partial V} q \cdot \pi \Delta t \, dS \, .$$

The simplified version of the first law of thermodynamics then states that

$$\int\_{V} \varrho(e(\mathbf{x}, t + \Delta t) - e(\mathbf{x}, t))dV = -\int\_{\partial V} \mathbf{q} \cdot \mathbf{n} \Delta t \, dS \, \mathcal{J}$$

The minus sign on the right-hand side ensures that the integral there models net *inflow* of heat (since n is an outward unit normal, q n models *outflow*). Dividing by t and notifying that

$$\lim\_{\Delta t \to 0} \frac{e(\mathbf{x}, t + \Delta t) - e(\mathbf{x}, t)}{\Delta t} = \frac{\partial e}{\partial t},$$

we get (in the limit t ! <sup>0</sup>)

$$\int\_{V} \varrho \frac{\partial \varrho}{\partial t} dV + \int\_{\partial V} \mathbf{q} \cdot \mathbf{n} \Delta t \, dS = 0 \,,$$

This is the integral equation for heat conduction, but we aim at a PDE. The next step is therefore to transform the surface integral to a volume integral via Gauss' divergence theorem. The result is

$$\int\_{V} \left( \varrho \frac{\partial \varrho}{\partial t} + \nabla \cdot \mathbf{q} \right) dV = 0 \,,$$

If this equality is to hold for all volumes V , the integrand must vanish, and we have the PDE

$$
\varrho \frac{\partial e}{\partial t} = -\nabla \cdot \mathbf{q} \,. \tag{3.123}
$$

Sometimes the supplied heat can come from the medium itself. This is the case, for instance, when radioactive rock generates heat. Let us add this effect. If f .x;t/ is the supplied heat per unit volume per unit time, the heat supplied in a small volume is f tV , and inside an arbitrary volume V the supplied generated heat becomes

$$\int\_{V} f \Delta t dV \,.$$

Adding this to the integral statement of the (simplified) first law of thermodynamics, and continuing the derivation, leads to the PDE

$$
\varrho \frac{\partial e}{\partial t} = -\nabla \cdot \mathbf{q} + f \,\,. \tag{3.124}
$$

There are four unknown scalar fields: <sup>e</sup> and q. Moreover, the temperature <sup>T</sup> , which is our primary quantity to compute, does not enter the model yet. We need an additional equation, called the *equation of state*, relating <sup>e</sup>, <sup>V</sup> D 1=% D, and <sup>T</sup> : <sup>e</sup> D e.V; T /. By the chain rule we have

$$\frac{\partial e}{\partial t} = \left. \frac{\partial e}{\partial T} \right|\_V \left. \frac{\partial T}{\partial t} + \left. \frac{\partial e}{\partial V} \right|\_T \frac{\partial V}{\partial t} \right.$$

The first coefficient @e=@T is called *specific heat capacity at constant volume*, denoted by cv :

$$c\_v = \left. \frac{\partial e}{\partial T} \right|\_V \cdot$$

The specific heat capacity will in general vary with T , but taking it as a constant is a good approximation in many applications.

The term @e=@V models effects due to compressibility and volume expansion. These effects are often small and can be neglected. We shall do so here. Using @e=@t <sup>D</sup> cv @T =@t in the PDE gives

$$
\varrho c\_v \frac{\partial T}{\partial t} = -\nabla \cdot q + f \,\,\,\,\,\,
$$

We still have four unknown scalar fields (<sup>T</sup> and q). To close the system, we need a relation between the heat flux q and the temperature <sup>T</sup> called *Fourier's law*:

$$
\boldsymbol{q} = -k \boldsymbol{\nabla} T,
$$

which simply states that heat flows from hot to cold areas, along the path of greatest variation. In a solid medium, k depends on the material of the medium, and in multimaterial media one must regard k as spatially dependent. In a fluid, it is common to assume that k is constant. The value of k reflects how easy heat is conducted through the medium, and k is named the *coefficient of heat conduction*.

We now have one scalar PDE for the unknown temperature field T .x;t/:

%cv @T @t Dr .krT / <sup>C</sup> f : (3.125)

#### **3.8.3 Porous Media Flow**

The requirement of mass balance for flow of a single, incompressible fluid through a deformable (elastic) porous medium leads to the equation

$$S\frac{\partial p}{\partial t} + \nabla \cdot \left(q - \alpha \frac{\partial \mathfrak{u}}{\partial t}\right) = 0,$$

where <sup>p</sup> is the fluid pressure, q is the fluid velocity, u is the displacement (deformation) of the medium, S is the storage coefficient of the medium (related to the compressibility of the fluid and the material in the medium), and ˛ is another coefficient. In many circumstances, the last term with u can be neglected, an assumption that decouples the equation above from a model for the deformation of the medium. The famous *Darcy's law* relates q to <sup>p</sup>:

$$q = -\frac{K}{\mu}(\nabla p - \varrho \mathbf{g}),$$

where K is the permeability of the medium, is the dynamic viscosity of the fluid, % is the density of the fluid, and g is the acceleration of gravity, here taken as g D gk. Combining the two equations results in the diffusion model

$$S\frac{\partial p}{\partial t} = \mu^{-1}\nabla(K\nabla p) + \frac{\varrho\mathbf{g}}{\mu}\frac{\partial K}{\partial z} \,. \tag{3.126}$$

Boundary conditions consist of specifying <sup>p</sup> or q n (i.e., normal velocity) at each point of the boundary.

#### **3.8.4 Potential Fluid Flow**

Let v be the velocity of a fluid. The condition r v D <sup>0</sup> is relevant for many flows, especially in geophysics when viscous effects are negligible. From vector calculus it is known that r v D <sup>0</sup> implies that <sup>v</sup> can be derived from a scalar potential field : v D r. If the fluid is incompressible, r v D <sup>0</sup>, it follows that rr D <sup>0</sup>, or

$$
\nabla^2 \phi = 0 \,. \tag{3.127}
$$

This Laplace equation is sufficient for determining and thereby describe the fluid motion. This type of flow is known as potential flow14. One very important application where potential flow is a good model is water waves. As boundary condition we must prescribe v n D @=@n. This gives rise to what is known as a pure Neumann problem and will cause numerical difficulties because and plus any constant are two solutions of the problem. The simplest remedy is to fix the value of at a point.

<sup>14</sup> https://en.wikipedia.org/wiki/Potential\_flow

#### **3.8.5 Streamlines for 2D Fluid Flow**

The streamlines in a two-dimensional stationary fluid flow are lines tangential to the flow. The stream function<sup>15</sup> is often introduced in two-dimensional flow such that its contour lines, D const, gives the streamlines. The relation between and the velocity field v D .u; v/ is

$$u = \frac{\partial \Psi}{\partial y}, \quad v = -\frac{\partial \Psi}{\partial x}.$$

It follows that <sup>r</sup><sup>v</sup> <sup>D</sup> yx xy <sup>D</sup> <sup>0</sup>, so the stream function can only be used for incompressible flows. Since

$$\nabla \times \boldsymbol{v} = \left(\frac{\partial \boldsymbol{v}}{\partial \boldsymbol{y}} - \frac{\partial \boldsymbol{u}}{\partial \boldsymbol{x}}\right) \boldsymbol{k} \equiv \boldsymbol{\omega} \boldsymbol{k},$$

we can derive the relation

$$
\nabla^2 \psi = -w,\tag{3.128}
$$

which is a governing equation for the stream function .x; y/ if the vorticity ! is known.

#### **3.8.6 The Potential of an Electric Field**

Under the assumption of time independence, Maxwell's equations for the electric field E become

$$
\begin{aligned}
\nabla \cdot E &= \frac{\rho}{\epsilon\_0}, \\
\nabla \times E &= 0,
\end{aligned}
$$

where is the electric charge density and 0 is the electric permittivity of free space (i.e., vacuum). Since r - E D <sup>0</sup>, E can be derived from a potential ', E D r'. The electric field potential is therefore governed by the Poisson equation

$$
\nabla^2 \varphi = -\frac{\rho}{\epsilon\_0}.\tag{3.129}
$$

If the medium is heterogeneous, will depend on the spatial location r. Also, <sup>0</sup> must be exchanged with an electric permittivity function .r/.

Each point of the boundary must be accompanied by, either a Dirichlet condition '.r/ D 'D.r/, or a Neumann condition @'.r/ @n <sup>D</sup> 'N .r/.

#### **3.8.7 Development of Flow Between Two Flat Plates**

Diffusion equations may also arise as simplified versions of other mathematical models, especially in fluid flow. Consider a fluid flowing between two flat, parallel

<sup>15</sup> https://en.wikipedia.org/wiki/Stream\_function

plates. The velocity is uni-directional, say along the z axis, and depends only on the distance <sup>x</sup> from the plates; u D u.x; t/k. The flow is governed by the Navier-Stokes equations,

$$
\varrho \frac{\partial \mathfrak{u}}{\partial t} + \varrho \mathfrak{u} \cdot \nabla \mathfrak{u} = -\nabla p + \mu \nabla^2 \mathfrak{u} + \varrho f,
$$

$$
\nabla \cdot \mathfrak{u} = 0,
$$

where <sup>p</sup> is the pressure field, unknown along with the velocity u, % is the fluid density, the dynamic viscosity, and f is some external body force. The geometric restrictions of flow between two flat plates puts restrictions on the velocity, u D u.x; t/i, and the <sup>z</sup> component of the Navier-Stokes equations collapses to a diffusion equation:

$$
\varrho \frac{\partial u}{\partial t} = -\frac{\partial p}{\partial z} + \mu \frac{\partial^2 u}{\partial z^2} + \varrho f\_z,
$$

if fz is the component of <sup>f</sup> in the <sup>z</sup> direction.

The boundary conditions are derived from the fact that the fluid sticks to the plates, which means u D <sup>0</sup> at the plates. Say the location of the plates are <sup>z</sup> D <sup>0</sup> and <sup>z</sup> D <sup>L</sup>. We then have

$$u(0,t) = u(L,t) = 0\,.$$

One can easily show that @p=@z must be a constant or just a function of time <sup>t</sup>. We set @p=@z D ˇ.t/. The body force could be a component of gravity, if desired, set as fz <sup>D</sup> g. Switching from z to x as independent variable gives a very standard one-dimensional diffusion equation:

$$
\varrho \frac{\partial u}{\partial t} = \mu \frac{\partial^2 u}{\partial x^2} + \beta(t) + \varrho \chi \mathbf{g}, \quad \mathbf{x} \in [0, L], \ t \in (0, T].
$$

The boundary conditions are

$$
u(0,t) = \boldsymbol{u}(L,t) = 0,$$

while some initial condition

$$u(\mathbf{x},0) = I(\mathbf{x})$$

must also be prescribed.

The flow is driven by either the pressure gradient ˇ or gravity, or a combination of both. One may also consider one moving plate that drives the fluid. If the plate at <sup>x</sup> <sup>D</sup> <sup>L</sup> moves with velocity UL.t/, we have the adjusted boundary condition

$$u(L, t) = U\_L(t) \, .$$

#### **3.8.8 Flow in a Straight Tube**

Now we consider viscous fluid flow in a straight tube with radius R and rigid walls. The governing equations are the Navier-Stokes equations, but as in Sect. 3.8.7, it is natural to assume that the velocity is directed along the tube, and that it is axisymmetric. These assumptions reduced the velocity field to u D u.r; x; t/i, if the <sup>x</sup> axis is directed along the tube. From the equation of continuity, r u D <sup>0</sup>, we see that <sup>u</sup> must be independent of <sup>x</sup>. Inserting u D u.r; t/i in the Navier-Stokes equations, expressed in axi-symmetric cylindrical coordinates, results in

$$\varrho \frac{\partial u}{\partial t} = \mu \frac{1}{r} \frac{\partial}{\partial r} \left( r \frac{\partial u}{\partial r} \right) + \beta(t) + \varrho \chi \mathbf{g}, \quad r \in [0, R], \ t \in (0, T]. \tag{3.130}$$

Here, ˇ.t/ D @p=@x is the pressure gradient along the tube. The associated boundary condition is u.R; t/ D <sup>0</sup>.

#### **3.8.9 Tribology: Thin Film Fluid Flow**

Thin fluid films are extremely important inside machinery to reduce friction between gliding surfaces. The mathematical model for the fluid motion takes the form of a diffusion problem and is quickly derived here. We consider two solid surfaces whose distance is described by a gap function h.x; y/. The space between these surfaces is filled with a fluid with dynamic viscosity . The fluid may move partially because of pressure gradients and partially because the surfaces move. Let <sup>U</sup>i C <sup>V</sup> j be the relative velocity of the two surfaces and <sup>p</sup> the pressure in the fluid. The mathematical model builds on two principles: 1) conservation of mass, 2) assumption of locally quasi-static flow between flat plates.

The conservation of mass equation reads r u, where u is the local fluid velocity. For thin films the detailed variation between the surfaces is not of interest, so ru D 0 is integrated (average) in the direction perpendicular to the surfaces. This gives rise to the alternative mass conservation equation

$$\nabla \cdot q = 0, \quad q = \int\_0^{h(\mathbf{x}, \mathbf{y})} \mathbf{u} dz, \quad$$

where <sup>z</sup> is the coordinate perpendicular to the surfaces, and q is then the volume flux in the fluid gap.

Locally, we may assume that we have steady flow between two flat surfaces, with a pressure gradient and where the lower surface is at rest and the upper moves with velocity <sup>U</sup>i C <sup>V</sup> j . The corresponding mathematical problem is actually the limit problem in Sect. 3.8.7 as <sup>t</sup> ! 1. The limit problem can be solved analytically, and the local volume flux becomes

$$\Psi(\mathbf{x}, \mathbf{y}, z) = \int\_0^h \mathfrak{u}(\mathbf{x}, \mathbf{y}, z) dz = -\frac{h^3}{12\mu} \nabla p + \frac{1}{2} Uh\mathbf{i} + \frac{1}{2} Vh \mathbf{j} \ .$$

The idea is to use this expression locally also when the surfaces are not flat, but slowly varying, and if U, V , or p varies in time, provided the time variation is sufficiently slow. This is a common quasi-static approximation, much used in mathematical modeling.

Inserting the expression for q via <sup>p</sup>, <sup>U</sup>, and <sup>V</sup> in the equation rq D <sup>0</sup> gives a diffusion PDE for p:

$$\nabla \cdot \left(\frac{h^3}{12\mu} \nabla p\right) = \frac{1}{2} \frac{\partial}{\partial \chi} (hU) + \frac{1}{2} \frac{\partial}{\partial \chi} (hV) \,. \tag{3.131}$$

The boundary conditions must involve <sup>p</sup> or q at the boundary.

#### **3.8.10 Propagation of Electrical Signals in the Brain**

One can make a model of how electrical signals are propagated along the neuronal fibers that receive synaptic inputs in the brain. The signal propagation is one-dimensional and can, in the simplest cases, be governed by the Cable equation16:

$$c\_m \frac{\partial V}{\partial t} = \frac{1}{r\_l} \frac{\partial^2 V}{\partial x^2} - \frac{1}{r\_m} V \tag{3.132}$$

where V .x; t/ is the voltage to be determined, cm is capacitance of the neuronal fiber, while rl and rm are measures of the resistance. The boundary conditions are often taken as <sup>V</sup> D <sup>0</sup> at a short circuit or open end, @V =@x D <sup>0</sup> at a sealed end, or @V =@x / <sup>V</sup> where there is an injection of current.

#### **3.9 Exercises**

#### **Exercise 3.6: Stabilizing the Crank-Nicolson method by Rannacher time stepping**

It is well known that the Crank-Nicolson method may give rise to non-physical oscillations in the solution of diffusion equations if the initial data exhibit jumps (see Sect. 3.3.6). Rannacher [15] suggested a stabilizing technique consisting of using the Backward Euler scheme for the first two time steps with step length <sup>1</sup> <sup>2</sup>t. One can generalize this idea to taking 2m time steps of size <sup>1</sup> <sup>2</sup>t with the Backward Euler method and then continuing with the Crank-Nicolson method, which is of second-order in time. The idea is that the high frequencies of the initial solution are quickly damped out, and the Backward Euler scheme treats these high frequencies correctly. Thereafter, the high frequency content of the solution is gone and the Crank-Nicolson method will do well.

Test this idea for <sup>m</sup> D 1; 2; 3 on a diffusion problem with a discontinuous initial condition. Measure the convergence rate using the solution (3.45) with the boundary conditions (3.46)–(3.47) for t values such that the conditions are in the vicinity of ˙1. For example, t < 5a1:6 <sup>10</sup><sup>2</sup> makes the solution diffusion from a step to almost a straight line. The program diffu\_erf\_sol.py shows how to compute the analytical solution.

#### **Project 3.7: Energy estimates for diffusion problems**

This project concerns so-called *energy estimates* for diffusion problems that can be used for qualitative analytical insight and for verification of implementations.

<sup>16</sup> http://en.wikipedia.org/wiki/Cable\_equation

a) We start with a 1D homogeneous diffusion equation with zero Dirichlet conditions:

$$u\_t = au\_\times \mathbf{x}, \quad \qquad \mathbf{x} \in \mathfrak{Q} = (0, L), \ t \in (0, T], \qquad (3.133)$$

$$u(0,t) = u(L,t) = 0, \qquad t \in (0,T], \tag{3.134}$$

$$u(\mathbf{x},0) = I(\mathbf{x}), \quad \qquad \qquad \mathbf{x} \in [0,L]. \tag{3.135}$$

The energy estimate for this problem reads

$$||u||\_{L^{2}} \le ||I||\_{L^{2}},\tag{3.136}$$

where the jj jjL2 norm is defined by

$$||\mathbf{g}||\_{L^2} = \sqrt{\int\_0^L \mathbf{g}^2 d\mathbf{x}}\,. \tag{3.137}$$

The quantify jjujjL2 or <sup>1</sup> <sup>2</sup> jjujjL2 is known as the *energy* of the solution, although it is not the physical energy of the system. A mathematical tradition has introduced the notion *energy* in this context.

The estimate (3.136) says that the "size of u" never exceeds that of the initial condition, or more precisely, it says that the area under the u curve decreases with time.

To show (3.136), multiply the PDE by u and integrate from 0 to L. Use that uut can be expressed as the time derivative of u<sup>2</sup> and that uxxu can integrated by parts to form an integrand u<sup>2</sup> <sup>x</sup>. Show that the time derivative of jjujj<sup>2</sup> L2 must be less than or equal to zero. Integrate this expression and derive (3.136).

b) Now we address a slightly different problem,

$$u\_t = \alpha u\_\lambda \mathbf{x} + f(\mathbf{x}, t), \qquad \mathbf{x} \in \Omega = (0, L), \ t \in (0, T], \qquad (3.138)$$

$$u(0,t) = u(L,t) = 0, \quad \qquad t \in (0,T], \tag{3.139}$$

$$u(\mathbf{x},0) = 0,\qquad\qquad\qquad\mathbf{x}\in\left[0,L\right].\tag{3.140}$$

The associated energy estimate is

$$||u||\_{L^{2}} \leq ||f||\_{L^{2}}\,.\tag{3.141}$$

(This result is more difficult to derive.)

Now consider the compound problem with an initial condition I.x/ and a righthand side f .x; t/:

$$u\_t = au\_\times x + f(\mathbf{x}, t), \qquad \mathbf{x} \in \mathcal{Q} = (0, L), \ t \in (0, T], \qquad (3.142)$$

$$u(0,t) = u(L,t) = 0, \quad \qquad t \in (0,T], \tag{3.143}$$

$$u(\mathbf{x},0) = I(\mathbf{x}), \quad \qquad \qquad \mathbf{x} \in \left[0, L\right]. \tag{3.144}$$

Show that if w1 fulfills (3.133)–(3.135) and w2 fulfills (3.138)–(3.140), then <sup>u</sup> <sup>D</sup> w1 <sup>C</sup> w2 is the solution of (3.142)–(3.144). Using the triangle inequality for norms,

$$||a+b|| \le ||a|| + ||b||,$$

show that the energy estimate for (3.142)–(3.144) becomes

$$||u||\_{L^{2}} \le ||I||\_{L^{2}} + ||f||\_{L^{2}} \,. \tag{3.145}$$


*Hint* Use integration by parts in multi dimensions:

$$\int\_{\varOmega} u \nabla \cdot (\alpha \nabla u) \, \mathrm{d}x = -\int\_{\varOmega} \alpha \nabla u \cdot \nabla u \, \mathrm{d}x + \int\_{\partial \varOmega} u \alpha \frac{\partial u}{\partial n},$$

where @u @n <sup>D</sup> <sup>n</sup> ru, <sup>n</sup> being the outward unit normal to the boundary @˝ of the domain ˝.

e) Now we also consider the multi-dimensional PDE ut Dr .˛ru/. Integrate both sides over ˝ and use Gauss' divergence theorem, R ˝ r <sup>q</sup> <sup>d</sup><sup>x</sup> <sup>D</sup> <sup>R</sup> @˝ <sup>q</sup><sup>n</sup> <sup>d</sup><sup>s</sup> for a vector field q. Show that if we have homogeneous Neumann conditions on the boundary, @u=@n D <sup>0</sup>, area under the <sup>u</sup> surface remains constant in time and

$$\int\_{\varOmega} u \,\mathrm{d}x = \int\_{\varOmega} I \,\mathrm{d}x \,. \tag{3.146}$$

f) Establish a code in 1D, 2D, or 3D that can solve a diffusion equation with a source term f , initial condition I , and zero Dirichlet or Neumann conditions on the whole boundary.

We can use (3.145) and (3.146) as a partial verification of the code. Choose some functions f and I and check that (3.145) is obeyed at any time when zero Dirichlet conditions are used. Iterate over the same I functions and check that (3.146) is fulfilled when using zero Neumann conditions.

g) Make a list of some possible bugs in the code, such as indexing errors in arrays, failure to set the correct boundary conditions, evaluation of a term at a wrong time level, and similar. For each of the bugs, see if the verification tests from the previous subexercise pass or fail. This investigation shows how strong the energy estimates and the estimate (3.146) are for pointing out errors in the implementation.

Filename: diffu\_energy.

#### **Exercise 3.8: Splitting methods and preconditioning**

In Sect. 3.6.15, we outlined a class of iterative methods for Au D <sup>b</sup> based on splitting <sup>A</sup> into <sup>A</sup> D <sup>M</sup> <sup>N</sup> and introducing the iteration

$$M u^k = N u^k + b \dots$$

The very simplest splitting is <sup>M</sup> D <sup>I</sup> , where <sup>I</sup> is the identity matrix. Show that this choice corresponds to the iteration

$$u^k = u^{k-1} + r^{k-1}, \quad r^{k-1} = b - Au^{k-1}, \tag{3.147}$$

where <sup>r</sup> <sup>k</sup><sup>1</sup> is the residual in the linear system in iteration <sup>k</sup> <sup>1</sup>. The formula (3.147) is known as Richardson's iteration. Show that if we apply the simple iteration method (3.147) to the *preconditioned* system <sup>M</sup>1Au D <sup>M</sup>1b, we arrive at the Jacobi method by choosing <sup>M</sup> D <sup>D</sup> (the diagonal of <sup>A</sup>) as preconditioner and the SOR method by choosing <sup>M</sup> D !1D C <sup>L</sup> (<sup>L</sup> being the lower triangular part of A). This equivalence shows that we can apply one iteration of the Jacobi or SOR method as preconditioner.

#### **Problem 3.9: Oscillating surface temperature of the earth**

Consider a day-and-night or seasonal variation in temperature at the surface of the earth. How deep down in the ground will the surface oscillations reach? For simplicity, we model only the vertical variation along a coordinate <sup>x</sup>, where <sup>x</sup> D <sup>0</sup> at the surface, and x increases as we go down in the ground. The temperature is governed by the heat equation

$$
\varrho c\_v \frac{\partial T}{\partial t} = \nabla \cdot (k \nabla T),
$$

in some spatial domain <sup>x</sup> 2 Œ0; L, where <sup>L</sup> is chosen large enough such that we can assume that T is approximately constant, independent of the surface oscillations, for x>L. The parameters %, cv, and k are the density, the specific heat capacity at constant volume, and the heat conduction coefficient, respectively.


Show that the scaled PDE is

$$
\frac{\partial u}{\partial \bar{t}} = \frac{1}{2} \frac{\partial^2 u}{\partial x^2},
$$

with initial condition u.x; 0/ N D <sup>0</sup>, left boundary condition u.0; t/<sup>N</sup> D sin.t /<sup>N</sup> , and right boundary condition u.L; <sup>N</sup> t /<sup>N</sup> D <sup>0</sup>. The bar indicates a dimensionless quantity.

Show that u.x;<sup>N</sup> t /<sup>N</sup> <sup>D</sup> <sup>e</sup> N<sup>x</sup> sin.xN t /<sup>N</sup> is a solution that fulfills the PDE and the boundary condition at <sup>x</sup>N D <sup>0</sup> (this is the solution we will experience as <sup>t</sup> N ! 1 and <sup>L</sup> ! 1). Conclude that an appropriate domain for <sup>x</sup> is Œ0; 4 if a damping <sup>e</sup><sup>4</sup> 0:18 is appropriate for implementing <sup>u</sup>N const; increasing to Œ0; 6 damps <sup>u</sup>N to 0.0025.

c) Compute the scaled temperature and make animations comparing two solutions with <sup>L</sup><sup>N</sup> D <sup>4</sup> and <sup>L</sup><sup>N</sup> D <sup>8</sup>, respectively (keep x the same).

#### **Problem 3.10: Oscillating and pulsating flow in tubes**

We consider flow in a straight tube with radius R and straight walls. The flow is driven by a pressure gradient ˇ.t/. The effect of gravity can be neglected. The mathematical problem reads

$$\varrho \frac{\partial u}{\partial t} = \mu \frac{1}{r} \frac{\partial}{\partial r} \left( r \frac{\partial u}{\partial r} \right) + \beta(t), \qquad r \in [0, R], \ t \in (0, T], \qquad (3.148)$$

$$u(r,0) = I(r), \quad \qquad r \in [0,R], \tag{3.149}$$

$$u(R,t) = 0, \quad \qquad \qquad t \in (0,T], \tag{3.150}$$

$$\frac{\partial u}{\partial r}(0,t) = 0, \quad \quad \quad \quad \quad \quad t \in (0,T]. \tag{3.151}$$

We consider two models for ˇ.t/. One plain, sinusoidal oscillation:

$$
\beta = A \sin(\omega t),
\tag{3.152}
$$

and one with periodic pulses,

$$
\beta = A \sin^{16}(\alpha t) \,. \tag{3.153}
$$

Note that both models can be written as <sup>ˇ</sup> D <sup>A</sup> sinm.!t/, with <sup>m</sup> D <sup>1</sup> and <sup>m</sup> D <sup>16</sup>, respectively.


*Hint* You need to include an extra source term in the equation to allow for such tests. Let the spatial variation be <sup>1</sup><sup>r</sup> <sup>2</sup> such that the boundary condition is fulfilled.


Filename: axisymm\_flow.

#### **Problem 3.11: Scaling a welding problem**

Welding equipment makes a very localized heat source that moves in time. We shall investigate the heating due to welding and choose, for maximum simplicity, a one-dimensional heat equation with a fixed temperature at the ends, and we neglect melting. We shall scale the problem, and besides solving such a problem numerically, the aim is to investigate the appropriateness of alternative scalings.

The governing PDE problem reads

$$\begin{aligned} \varrho c \frac{\partial u}{\partial t} &= k \frac{\partial^2 u}{\partial x^2} + f, & x \in (0, L), \ t \in (0, T), \\ u(x, 0) &= U\_s, & x \in [0, L], \\ u(0, t) &= u(L, t) = 0, & t \in (0, T]. \end{aligned}$$

Here, u is the temperature, % the density of the material, c a heat capacity, k the heat conduction coefficient, f is the heat source from the welding equipment, and Us is the initial constant (room) temperature in the material.

A possible model for the heat source is a moving Gaussian function:

$$f = A \exp\left(-\frac{1}{2} \left(\frac{\mathbf{x} - \upsilon t}{\sigma}\right)^2\right).$$

where A is the strength, is a parameter governing how peak-shaped (or localized in space) the heat source is, and v is the velocity (in positive x direction) of the source.

a) Let xc , tc , uc , and fc be scales, i.e., characteristic sizes, of x, t, u, and f , respectively. The natural choice of xc and fc is L and A, since these make the scaled x and f in the interval Œ0; 1. If each of the three terms in the PDE are equally important, we can find tc and uc by demanding that the coefficients in the scaled PDE are all equal to unity. Perform this scaling. Use scaled quantities in the arguments for the exponential function in f too and show that

$$
\bar{f} = e^{-\frac{1}{2}\beta^2(\bar{\imath} - \gamma \bar{\imath})^2},
$$

where ˇ and are dimensionless numbers. Give an interpretation of ˇ and .

b) Argue that for large we should base the time scale on the movement of the heat source. Show that this gives rise to the scaled PDE

$$
\frac{\partial \bar{u}}{\partial \bar{t}} = \gamma^{-1} \frac{\partial^2 \bar{u}}{\partial \bar{x}^2} + \bar{f},
$$

and

$$\bar{f} = \exp\left(-\frac{1}{2}\beta^2(\bar{\mathbf{x}} - \bar{t})^2\right).$$

Discuss when the scalings in a) and b) are appropriate.

c) One aim with scaling is to get a solution that lies in the interval <sup>Œ</sup>1; 1. This is not always the case when uc is based on a scale involving a source term, as we do in a) and b). However, from the scaled PDE we realize that if we replace <sup>f</sup><sup>N</sup> with ıfN, where <sup>ı</sup> is a dimensionless factor, this corresponds to replacing uc by uc=ı. So, if we observe that <sup>u</sup>N 1=ı in simulations, we can just replace <sup>f</sup><sup>N</sup> by ıf<sup>N</sup> in the scaled PDE.

Use this trick and implement the two scaled models. Reuse software for the diffusion equation (e.g., the solver function in diffu1D\_vc.py). Make a function run(gamma, beta=10, delta=40, scaling=1, animate=False) that runs the model with the given , ˇ, and ı parameters as well as an indicator scaling that is 1 for the scaling in a) and 2 for the scaling in b). The last argument can be used to turn screen animations on or off.

Experiments show that with D <sup>1</sup> and <sup>ˇ</sup> D <sup>10</sup>, <sup>ı</sup> D <sup>20</sup> is appropriate. Then max j Nuj will be larger than 4 for D <sup>40</sup>, but that is acceptable.

Equip the run function with visualization, both animation of <sup>u</sup>N and <sup>f</sup>N, and plots with <sup>u</sup>N and <sup>f</sup><sup>N</sup> for <sup>t</sup> D 0:2 and <sup>t</sup> D 0:5.

*Hint* Since the amplitudes of <sup>u</sup>N and <sup>f</sup><sup>N</sup> differs by a factor <sup>ı</sup>, it is attractive to plot f =ı <sup>N</sup> together with <sup>u</sup>N.

d) Use the software in c) to investigate D 0:2; 1; 5; 40 for the two scalings. Discuss the results.

Filename: welding.

#### **Exercise 3.12: Implement a Forward Euler scheme for axi-symmetric diffusion**

Based on the discussion in Sect. 3.5.6, derive in detail the discrete equations for a Forward Euler in time, centered in space, finite difference method for axi-symmetric diffusion. The diffusion coefficient may be a function of the radial coordinate. At the outer boundary <sup>r</sup> D <sup>R</sup>, we may have either a Dirichlet or Robin condition. Implement this scheme. Construct appropriate test problems. Filename: FE\_axisym.

The images or other third party material in this chapter are included in the chapter's Creative Commons license, unless indicated otherwise in a credit line to the material. If material is not included in the chapter's Creative Commons license and your intended use is not permitted by statutory regulation or exceeds the permitted use, you will need to obtain permission directly from the copyright holder.

**Open Access** This chapter is licensed under the terms of the Creative Commons Attribution 4.0 International License (http://creativecommons.org/licenses/by/4.0/), which permits use, sharing, adaptation, distribution and reproduction in any medium or format, as long as you give appropriate credit to the original author(s) and the source, provide a link to the Creative Commons license and indicate if changes were made.

# **4 Advection-Dominated Equations**

Wave (Chap. 2) and diffusion (Chap. 3) equations are solved reliably by finite difference methods. As soon as we add a first-order derivative in space, representing *advective* transport (also known as *convective* transport), the numerics gets more complicated and intuitively attractive methods no longer work well. We shall show how and why such methods fail and provide remedies. The present chapter builds on basic knowledge about finite difference methods for diffusion and wave equations, including the analysis by Fourier components, truncation error analysis (Appendix B), and compact difference notation.

#### **Remark on terminology**

It is common to refer to movement of a fluid as convection, while advection is the transport of some material dissolved or suspended in the fluid. We shall mostly choose the word advection here, but both terms are in heavy use, and for mass transport of a substance the PDE has an advection term, while the similar term for the heat equation is a convection term.

Much more comprehensive discussion of dispersion analysis for advection problems can be found in the book by Duran [3]. This is a an excellent resource for further studies on the topic of advection PDEs, with emphasis on generalizations to real geophysical problems. The book by Fletcher [4] also has a good overview of methods for advection and convection problems.

#### **4.1 One-Dimensional Time-Dependent Advection Equations**

We consider the pure advection model

$$\begin{aligned} \frac{\partial u}{\partial t} + v \frac{\partial u}{\partial x} &= 0, \quad & \quad & x \in (0, L), \ t \in (0, T], \qquad (4.1) \end{aligned}$$

u.x; 0/ D I.x/; x 2 .0; L/; (4.2)

$$u(0,t) = U\_0, \quad \qquad \qquad t \in (0,T]. \tag{4.3}$$

In (4.1), v is a given parameter, typically reflecting the transport velocity of a quantity u with a flow. There is only one boundary condition (4.3) since the spatial

derivative is only first order in the PDE (4.1). The information at <sup>x</sup> D <sup>0</sup> and the initial condition get transported in the positive x direction if v>0 through the domain.

It is easiest to find the solution of (4.1) if we remove the boundary condition and consider a process on the infinite domain .1;1/. The solution is simply

$$u(\mathbf{x}, t) = I(\mathbf{x} - vt) \,. \tag{4.4}$$

This is also the solution we expect locally in a finite domain before boundary conditions have reflected or modified the wave.

A particular feature of the solution (4.4) is that

$$u(\mathbf{x}\_i, t\_{n+1}) = u(\mathbf{x}\_{i-1}, t\_n), \tag{4.5}$$

if xi <sup>D</sup> ix and tn <sup>D</sup> nt are points in a uniform mesh. We see this relation from

$$\begin{aligned} u(i\Delta x, (n+1)\Delta t) &= I(i\Delta x - v(n+1)\Delta t) \\ &= I((i-1)\Delta x - vn\Delta t - v\Delta t + \Delta x) \\ &= I((i-1)\Delta x - vn\Delta t) \\ &= u((i-1)\Delta x, n\Delta t), \end{aligned}$$

provided <sup>v</sup> D x=t. So, whenever we see a scheme that collapses to

$$
\boldsymbol{u}\_{i}^{n+1} = \boldsymbol{u}\_{i-1}^{n},\tag{4.6}
$$

for the PDE in question, we have in fact a scheme that reproduces the analytical solution, and many of the schemes to be presented possess this nice property!

Finally, we add that a discussion of appropriate boundary conditions for the advection PDE in multiple dimensions is a challenging topic beyond the scope of this text.

#### **4.1.1 Simplest Scheme: Forward in Time, Centered in Space**

**Method** A first attempt to solve a PDE like (4.1) will normally be to look for a time-discretization scheme that is explicit so we avoid solving systems of linear equations. In space, we anticipate that centered differences are most accurate and therefore best. These two arguments lead us to a Forward Euler scheme in time and centered differences in space:

$$[D\_l^+u + vD\_{2\chi}u = 0]\_l^n \,. \tag{4.7}$$

Written out, we see that this expression implies that

$$u^{n+1} = u^n - \frac{1}{2}C(u\_{i+1}^n - u\_{i-1}^n),$$

with C as the Courant number

$$C = \frac{v\Delta t}{\Delta x}.$$

**Implementation** A solver function for our scheme goes as follows.

```
import numpy as np
import matplotlib.pyplot as plt
def solver_FECS(I, U0, v, L, dt, C, T, user_action=None):
    Nt = int(round(T/float(dt)))
    t = np.linspace(0, Nt*dt, Nt+1) # Mesh points in time
    dx = v*dt/C
    Nx = int(round(L/dx))
    x = np.linspace(0, L, Nx+1) # Mesh points in space
    # Make sure dx and dt are compatible with x and t
    dx = x[1] - x[0]
    dt = t[1] - t[0]
    C = v*dt/dx
    u = np.zeros(Nx+1)
    u_n = np.zeros(Nx+1)
    # Set initial condition u(x,0) = I(x)
    for i in range(0, Nx+1):
        u_n[i] = I(x[i])
    if user_action is not None:
        user_action(u_n, x, t, 0)
    for n in range(0, Nt):
        # Compute u at inner mesh points
        for i in range(1, Nx):
            u[i] = u_n[i] - 0.5*C*(u_n[i+1] - u_n[i-1])
        # Insert boundary condition
        u[0] = U0
        if user_action is not None:
            user_action(u, x, t, n+1)
        # Switch variables before next step
        u_n, u = u, u_n
```
**Test cases** The typical solution u has the shape of I and is transported at velocity v to the right (if v>0). Let us consider two different initial conditions, one smooth (Gaussian pulse) and one non-smooth (half-truncated cosine pulse):

$$u(\mathbf{x},0) = A e^{-\frac{1}{2} \left(\frac{\mathbf{x} - L/10}{\sigma}\right)^2},\tag{4.8}$$

$$u(\mathbf{x},0) = A\cos\left(\frac{5\pi}{L}\left(\mathbf{x} - \frac{L}{10}\right)\right), \quad \mathbf{x} < \frac{L}{5} \text{ else } 0. \tag{4.9}$$

The parameter A is the maximum value of the initial condition.

Before doing numerical simulations, we scale the PDE problem and introduce <sup>x</sup>N D x=L and <sup>t</sup> <sup>N</sup> D vt=L, which gives

$$\frac{\partial \bar{u}}{\partial \bar{t}} + \frac{\partial \bar{u}}{\partial \bar{x}} = 0 \,.$$

The unknown <sup>u</sup> is scaled by the maximum value of the initial condition: <sup>u</sup>N D u= max jI.x/j such that j Nu.x; 0/ N j 2 Œ0; 1. The scaled problem is solved by setting <sup>v</sup> D <sup>1</sup>, <sup>L</sup> D <sup>1</sup>, and <sup>A</sup> D <sup>1</sup>. From now on we drop the bars.

To run our test cases and plot the solution, we make the function

```
def run_FECS(case):
    """Special function for the FECS case."""
    if case == 'gaussian':
        def I(x):
            return np.exp(-0.5*((x-L/10)/sigma)**2)
    elif case == 'cosinehat':
        def I(x):
            return np.cos(np.pi*5/L*(x - L/10)) if x < L/5 else 0
    L = 1.0
    sigma = 0.02
    legends = []
    def plot(u, x, t, n):
        """Animate and plot every m steps in the same figure."""
        plt.figure(1)
        if n == 0:
            lines = plot(x, u)
        else:
            lines[0].set_ydata(u)
            plt.draw()
            #plt.savefig()
        plt.figure(2)
        m = 40
        if n % m != 0:
            return
        print 't=%g, n=%d, u in [%g, %g] w/%d points' % \
              (t[n], n, u.min(), u.max(), x.size)
        if np.abs(u).max() > 3: # Instability?
            return
        plt.plot(x, u)
        legends.append('t=%g' % t[n])
        if n > 0:
            plt.hold('on')
    plt.ion()
    U0 = 0
    dt = 0.001
    C = 1
    T = 1
    solver(I=I, U0=U0, v=1.0, L=L, dt=dt, C=C, T=T,
           user_action=plot)
    plt.legend(legends, loc='lower left')
    plt.savefig('tmp.png'); plt.savefig('tmp.pdf')
    plt.axis([0, L, -0.75, 1.1])
    plt.show()
```
**Bug?** Running either of the test cases, the plot becomes a mess, and the printout of u values in the plot function reveals that u grows very quickly. We may reduce t and make it very small, yet the solution just grows. Such behavior points to a bug in the code. However, choosing a coarse mesh and performing one time step by hand calculations produces the same numbers as the code, so the implementation seems to be correct. The hypothesis is therefore that the solution is unstable.

#### **4.1.2 Analysis of the Scheme**

It is easy to show that a typical Fourier component

$$u(\mathbf{x},t) = B\sin(k(\mathbf{x} - ct))$$

is a solution of our PDE for any spatial wave length D 2=k and any amplitude B. (Since the PDE to be investigated by this method is homogeneous and linear, B will always cancel out, so we tend to skip this amplitude, but keep it here in the beginning for completeness.)

A general solution may be viewed as a collection of long and short waves with different amplitudes. Algebraically, the work simplifies if we introduce the complex Fourier component

$$u(\mathbf{x}, t) = A\_{\mathbf{e}} e^{ik\boldsymbol{\alpha}},$$

with

$$A\_{\mathbf{c}} = B e^{-ikv\Delta t} = B e^{-iCk\Delta x}.$$

Note that <sup>j</sup>Aej <sup>1</sup>.

It turns out that many schemes also allow a Fourier wave component as solution, and we can use the numerically computed values of Ae (denoted A) to learn about the quality of the scheme. Hence, to analyze the difference scheme we have just implemented, we look at how it treats the Fourier component

$$u\_q^n = A^n e^{ikq \Delta x} \dots$$

Inserting the numerical component in the scheme,

$$[D\_l^+ A e^{ikq \Delta \chi} + \upsilon D\_{2\chi} A e^{ikq \Delta \chi} = 0]\_q^n,$$

and making use of (A.25) results in

$$\left[ e^{ikq\Delta x} \left( \frac{A - 1}{\Delta t} + v \frac{1}{\Delta x} i \sin(k \Delta x) \right) = 0 \right]\_q^n.$$

which implies

$$A = 1 - iC\sin(k\Delta x)\,.$$

The numerical solution features the formula An. To find out whether An means growth in time, we rewrite <sup>A</sup> in polar form: <sup>A</sup> <sup>D</sup> Ar <sup>e</sup>i, for real numbers Ar and , since we then have An D An <sup>r</sup> ein. The magnitude of An is An <sup>r</sup> . In our case, Ar <sup>D</sup> .1 <sup>C</sup> <sup>C</sup><sup>2</sup> sin2.kx//1=2 > 1, so An <sup>r</sup> will increase in time, whereas the exact solution will not. Regardless of t, we get unstable numerical solutions.

#### **4.1.3 Leapfrog in Time, Centered Differences in Space**

**Method** Another explicit scheme is to do a "leapfrog" jump over 2t in time and combine it with central differences in space:

$$[D\_{2t}u + vD\_{2x}u = 0]\_t^n,$$

which results in the updating formula

$$u\_i^{n+1} = u\_i^{n-1} - C(u\_{i+1}^n - u\_{i-1}^n) \ .$$

A special scheme is needed to compute u<sup>1</sup>, but we leave that problem for now. Anyway, this special scheme can be found in advec1D.py.

**Implementation** We now need to work with three time levels and must modify our solver a bit:

```
Nt = int(round(T/float(dt)))
t = np.linspace(0, Nt*dt, Nt+1) # Mesh points in time
...
u = np.zeros(Nx+1)
u_1 = np.zeros(Nx+1)
u_2 = np.zeros(Nx+1)
...
for n in range(0, Nt):
    if scheme == 'FE':
        for i in range(1, Nx):
            u[i] = u_1[i] - 0.5*C*(u_1[i+1] - u_1[i-1])
    elif scheme == 'LF':
        if n == 0:
            # Use some scheme for the first step
            for i in range(1, Nx):
                ...
        else:
            for i in range(1, Nx+1):
                u[i] = u_2[i] - C*(u_1[i] - u_1[i-1])
    # Switch variables before next step
    u_2, u_1, u = u_1, u, u_2
```
**Running a test case** Let us try a coarse mesh such that the smooth Gaussian initial condition is represented by 1 at mesh node 1 and 0 at all other nodes. This triangular initial condition should then be advected to the right. Choosing scaled variables as t D 0:1, <sup>T</sup> D <sup>1</sup>, and <sup>C</sup> D <sup>1</sup> gives the plot in Fig. 4.1, which is in fact identical to the exact solution (!).

**Running more test cases** We can run two types of initial conditions for <sup>C</sup> D 0:8: one very smooth with a Gaussian function (Fig. 4.2) and one with a discontinuity in the first derivative (Fig. 4.3). Unless we have a very fine mesh, as in the left plots in the figures, we get small ripples behind the main wave, and this main wave has the amplitude reduced.

**Fig. 4.1** Exact solution obtained by Leapfrog scheme with t D 0:1 and <sup>C</sup> D <sup>1</sup>

**Fig. 4.2** Advection of a Gaussian function with a leapfrog scheme and <sup>C</sup> D 0:8, t D 0:001 (*left*) and t D 0:01 (*right*)

Advection of the Gaussian function with a leapfrog scheme, using <sup>C</sup> D 0:8 and t <sup>D</sup> 0:01 can be seen in a movie file1. Alternatively, with t D 0:001, we get this movie file2.

Advection of the cosine hat function with a leapfrog scheme, using <sup>C</sup> D 0:8 and t D 0:01 can be seen in a movie file3. Alternatively, with t D 0:001, we get this movie file4.

<sup>1</sup> http://tinyurl.com/gokgkov/mov-advec/gaussian/LF/C08\_dt01.ogg

<sup>2</sup> http://tinyurl.com/gokgkov/mov-advec/gaussian/LF/C08\_dt001.ogg

<sup>3</sup> http://tinyurl.com/gokgkov/mov-advec/cosinehat/LF/C08\_dt01.ogg

<sup>4</sup> http://tinyurl.com/gokgkov/mov-advec/cosinehat/LF/C08\_dt001.ogg

**Fig. 4.3** Advection of half a cosine function with a leapfrog scheme and <sup>C</sup> D 0:8, t D 0:001 (*left*) and t D 0:01 (*right*)

**Analysis** We can perform a Fourier analysis again. Inserting the numerical Fourier component in the Leapfrog scheme, we get

$$A^2 - i2C\sin(k\Delta x)A - 1 = 0,$$

and

$$A = -iC\sin(k\Delta x) \pm \sqrt{1 - C^2\sin^2(k\Delta x)}\ \dots$$

Rewriting to polar form, <sup>A</sup> <sup>D</sup> Arei, we see that Ar <sup>D</sup> <sup>1</sup>, so the numerical component is neither increasing nor decreasing in time, which is exactly what we want. However, for C >1, the square root can become complex valued, so stability is obtained only as long as <sup>C</sup> <sup>1</sup>.

#### **Stability**

For all the working schemes to be presented in this chapter, we get the stability condition <sup>C</sup> <sup>1</sup>:

$$
\Delta t \le \frac{\Delta x}{v}.
$$

This is called the CFL condition and applies almost always to successful schemes for advection problems. Of course, one can use Crank-Nicolson or Backward Euler schemes for increased and even unconditional stability (no t restrictions), but these have other less desired damping problems.

We introduce <sup>p</sup> D kx. The amplification factor now reads

$$A = -iC\sin p \pm \sqrt{1 - C^2 \sin^2 p},$$

and is to be compared to the exact amplification factor

$$A\_{\mathfrak{C}} = e^{-ik \, v \Delta t} = e^{-ik \, C \Delta x} = e^{-iCp} \, .$$

Section 4.1.9 compares numerical amplification factors of many schemes with the exact expression.

**Fig. 4.4** Advection of a Gaussian function with a forward in time, upwind in space scheme and <sup>C</sup> D 0:8, t D 0:01 (*left*) and t D 0:001 (*right*)

#### **4.1.4 Upwind Differences in Space**

Since the PDE reflects transport of information along with a flow in positive x direction, when v>0, it could be natural to go (what is called) upstream and not downstream in the spatial derivative to collect information about the change of the function. That is, we approximate

$$\frac{\partial u}{\partial \boldsymbol{x}}(\boldsymbol{x}\_{i}, t\_{n}) \approx [D\_{\boldsymbol{x}}^{-} \boldsymbol{u}]\_{i}^{n} = \frac{\boldsymbol{u}\_{i}^{n} - \boldsymbol{u}\_{i-1}^{n}}{\Delta \boldsymbol{x}}\ .$$

This is called an *upwind difference* (the corresponding difference in the time direction would be called a backward difference, and we could use that name in space too, but *upwind* is the common name for a difference against the flow in advection problems). This spatial approximation does magic compared to the scheme we had with Forward Euler in time and centered difference in space. With an upwind difference,

$$[D\_l^+u + \upsilon D\_\chi^-u = 0]\_l^n,\tag{4.10}$$

written out as

$$
u\_i^{n+1} = 
u\_i^n - C(
u\_i^n - 
u\_{i-1}^n),$$

gives a generally popular and robust scheme that is stable if <sup>C</sup> <sup>1</sup>. As with the Leapfrog scheme, it becomes exact if <sup>C</sup> D <sup>1</sup>, exactly as shown in Fig. 4.1. This is easy to see since <sup>C</sup> D <sup>1</sup> gives the property (4.6). However, any C <1 gives a significant reduction in the amplitude of the solution, which is a purely numerical effect, see Fig. 4.4 and 4.5. Experiments show, however, that reducing t or x, while keeping C reduces the error.

Advection of the Gaussian function with a forward in time, upwind in space scheme, using <sup>C</sup> <sup>D</sup> 0:8 and t <sup>D</sup> 0:01 can be seen in a movie file5. Alternatively, with t D 0:005, we get this movie file6.

<sup>5</sup> http://tinyurl.com/gokgkov/mov-advec/gaussian/UP/C08\_dt001/movie.ogg

<sup>6</sup> http://tinyurl.com/gokgkov/mov-advec/gaussian/UP/C08\_dt0005/movie.ogg

**Fig. 4.5** Advection of half a cosine function with a forward in time, upwind in space scheme and <sup>C</sup> D 0:8, t D 0:001 (*left*) and t D 0:01 (*right*)

Advection of the cosine hat function with a forward in time, upwind in space scheme, using <sup>C</sup> D 0:8 and t D 0:01 can be seen in a movie file7. Alternatively, with t D 0:001, we get this movie file8.

The amplification factor can be computed using the formula (A.23),

$$\frac{A-1}{\Delta t} + \frac{\upsilon}{\Delta x}(\mathbf{l} - e^{-ik\Delta x}) = 0,$$

which means

$$A = 1 - C(1 - \cos(p) - i\sin(p))\dots$$

For C <1 there is, unfortunately, non-physical damping of discrete Fourier components, giving rise to reduced amplitude of u<sup>n</sup> <sup>i</sup> as in Fig. 4.4 and 4.5. The damping seen in these figures is quite severe. Stability requires <sup>C</sup> <sup>1</sup>.

#### **Interpretation of upwind difference as artificial diffusion**

One can interpret the upwind difference as extra, artificial diffusion in the equation. Solving

$$
\frac{\partial u}{\partial t} + v \frac{\partial u}{\partial x} = \nu \frac{\partial^2 u}{\partial x^2},
$$

by a forward difference in time and centered differences in space,

$$D\_t^+u + \upsilon D\_{2\times}u = \upsilon D\_{\ge}D\_{\ge}u]\_i^n,$$

actually gives the upwind scheme (4.10) if D vx=2. That is, solving the PDE ut <sup>C</sup> vux <sup>D</sup> <sup>0</sup> by centered differences in space and forward difference in time is unsuccessful, but by adding some artificial diffusion uxx, the method becomes stable:

$$\frac{\partial u}{\partial t} + v \frac{\partial u}{\partial x} = \left(\alpha + \frac{v \Delta x}{2}\right) \frac{\partial^2 u}{\partial x^2}.$$

<sup>7</sup> http://tinyurl.com/gokgkov/mov-advec/cosinehat/UP/C08\_dt01.ogg

<sup>8</sup> http://tinyurl.com/gokgkov/mov-advec/cosinehat/UP/C08\_dt001.ogg

#### **4.1.5 Periodic Boundary Conditions**

So far, we have given the value on the left boundary, u<sup>n</sup> <sup>0</sup> , and used the scheme to propagate the solution signal through the domain. Often, we want to follow such signals for long time series, and periodic boundary conditions are then relevant since they enable a signal that leaves the right boundary to immediately enter the left boundary and propagate through the domain again.

The periodic boundary condition is

$$
\mu(0, t) = \mu(L, t), \quad \mu\_0'' = \mu\_{N\_x}'' \dots
$$

It means that we in the first equation, involving u<sup>n</sup> <sup>0</sup>, insert u<sup>n</sup> Nx , and that we in the last equation, involving u<sup>n</sup>C<sup>1</sup> Nx insert <sup>u</sup><sup>n</sup>C<sup>1</sup> <sup>0</sup> . Normally, we can do this in the simple way that u\_1[0] is updated as u\_1[Nx] at the beginning of a new time level.

In some schemes we may need u<sup>n</sup> NxC<sup>1</sup> and <sup>u</sup><sup>n</sup> <sup>1</sup>. Periodicity then means that these values are equal to u<sup>n</sup> <sup>1</sup> and u<sup>n</sup> Nx<sup>1</sup>, respectively. For the upwind scheme, it is sufficient to set u\_1[0]=u\_1[Nx] at a new time level before computing u[1]. This ensures that u[1] becomes right and at the next time level u[0] at the current time level is correctly updated. For the Leapfrog scheme we must update u[0] and u[Nx] using the scheme:

```
if periodic_bc:
    i = 0
    u[i] = u_2[i] - C*(u_1[i+1] - u_1[Nx-1])
for i in range(1, Nx):
    u[i] = u_2[i] - C*(u_1[i+1] - u_1[i-1])
if periodic_bc:
    u[Nx] = u[0]
```
#### **4.1.6 Implementation**

**Test condition** Analytically, we can show that the integral in space under the u.x; t/ curve is constant:

$$\begin{aligned} \int\_0^L \left(\frac{\partial u}{\partial t} + v \frac{\partial u}{\partial x}\right) dx &= 0\\ \frac{\partial}{\partial t} \int\_0^L u dx &= -\int\_0^L v \frac{\partial u}{\partial x} dx \\ \frac{\partial u}{\partial t} \int\_0^L u dx &= [vu]\_0^L = 0 \end{aligned}$$

as long as u.0/ D u.L/ D <sup>0</sup>. We can therefore use the property

$$\int\_{0}^{L} u(x,t)dx = \text{const}$$

as a partial verification during the simulation. Now, any numerical method with <sup>C</sup> ¤ <sup>1</sup> will deviate from the constant, expected value, so the integral is a measure of the error in the scheme. The integral can be computed by the Trapezoidal integration rule

dx\*(0.5\*u[0] + 0.5\*u[Nx] + np.sum(u[1:-1]))

if u is an array holding the solution.

**The code** An appropriate solver function for multiple schemes may go as shown below.

```
def solver(I, U0, v, L, dt, C, T, user_action=None,
           scheme='FE', periodic_bc=True):
    Nt = int(round(T/float(dt)))
    t = np.linspace(0, Nt*dt, Nt+1) # Mesh points in time
    dx = v*dt/C
    Nx = int(round(L/dx))
    x = np.linspace(0, L, Nx+1) # Mesh points in space
    # Make sure dx and dt are compatible with x and t
    dx = x[1] - x[0]
    dt = t[1] - t[0]
    C = v*dt/dx
    print 'dt=%g, dx=%g, Nx=%d, C=%g' % (dt, dx, Nx, C)
    u = np.zeros(Nx+1)
    u_n = np.zeros(Nx+1)
    u_nm1 = np.zeros(Nx+1)
    integral = np.zeros(Nt+1)
    # Set initial condition u(x,0) = I(x)
    for i in range(0, Nx+1):
        u_n[i] = I(x[i])
    # Insert boundary condition
    u[0] = U0
    # Compute the integral under the curve
    integral[0] = dx*(0.5*u_n[0] + 0.5*u_n[Nx] + np.sum(u_n[1:-1]))
    if user_action is not None:
        user_action(u_n, x, t, 0)
```

```
for n in range(0, Nt):
    if scheme == 'FE':
        if periodic_bc:
            i = 0
            u[i] = u_n[i] - 0.5*C*(u_n[i+1] - u_n[Nx])
            u[Nx] = u[0]
        for i in range(1, Nx):
            u[i] = u_n[i] - 0.5*C*(u_n[i+1] - u_n[i-1])
    elif scheme == 'LF':
        if n == 0:
            # Use upwind for first step
            if periodic_bc:
                i = 0
                u_n[i] = u_n[Nx]
            for i in range(1, Nx+1):
                u[i] = u_n[i] - C*(u_n[i] - u_n[i-1])
        else:
            if periodic_bc:
                i = 0
                u[i] = u_nm1[i] - C*(u_n[i+1] - u_n[Nx-1])
            for i in range(1, Nx):
                u[i] = u_nm1[i] - C*(u_n[i+1] - u_n[i-1])
            if periodic_bc:
                u[Nx] = u[0]
    elif scheme == 'UP':
        if periodic_bc:
            u_n[0] = u_n[Nx]
        for i in range(1, Nx+1):
            u[i] = u_n[i] - C*(u_n[i] - u_n[i-1])
    else:
        raise ValueError('scheme="%s" not implemented' % scheme)
    if not periodic_bc:
        # Insert boundary condition
        u[0] = U0
    # Compute the integral under the curve
    integral[n+1] = dx*(0.5*u[0] + 0.5*u[Nx] + np.sum(u[1:-1]))
    if user_action is not None:
        user_action(u, x, t, n+1)
    # Switch variables before next step
    u_nm1, u_n, u = u_n, u, u_nm1
return integral
```
**Solving a specific problem** We need to call up the solver function in some kind of administering problem solving function that can solve specific problems and make appropriate visualization. The function below makes both static plots, screen animation, and hard copy videos in various formats.

```
def run(scheme='UP', case='gaussian', C=1, dt=0.01):
    """General admin routine for explicit and implicit solvers."""
    if case == 'gaussian':
        def I(x):
            return np.exp(-0.5*((x-L/10)/sigma)**2)
    elif case == 'cosinehat':
        def I(x):
            return np.cos(np.pi*5/L*(x - L/10)) if x < L/5 else 0
    L = 1.0
    sigma = 0.02
    global lines # needs to be saved between calls to plot
    def plot(u, x, t, n):
        """Plot t=0 and t=0.6 in the same figure."""
        plt.figure(1)
        global lines
        if n == 0:
            lines = plt.plot(x, u)
            plt.axis([x[0], x[-1], -0.5, 1.5])
            plt.xlabel('x'); plt.ylabel('u')
            plt.axes().set_aspect(0.15)
            plt.savefig('tmp_%04d.png' % n)
            plt.savefig('tmp_%04d.pdf' % n)
        else:
            lines[0].set_ydata(u)
            plt.axis([x[0], x[-1], -0.5, 1.5])
            plt.title('C=%g, dt=%g, dx=%g' %
                      (C, t[1]-t[0], x[1]-x[0]))
            plt.legend(['t=%.3f' % t[n]])
            plt.xlabel('x'); plt.ylabel('u')
            plt.draw()
            plt.savefig('tmp_%04d.png' % n)
        plt.figure(2)
        eps = 1E-14
        if abs(t[n] - 0.6) > eps and abs(t[n] - 0) > eps:
            return
        print 't=%g, n=%d, u in [%g, %g] w/%d points' % \
              (t[n], n, u.min(), u.max(), x.size)
        if np.abs(u).max() > 3: # Instability?
            return
        plt.plot(x, u)
        plt.hold('on')
        plt.draw()
        if n > 0:
            y = [I(x_-v*t[n]) for x_ in x]
            plt.plot(x, y, 'k--')
            if abs(t[n] - 0.6) < eps:
                filename = ('tmp_%s_dt%s_C%s' % \
                            (scheme, t[1]-t[0], C)).replace('.', '')
                np.savez(filename, x=x, u=u, u_e=y)
```

```
plt.ion()
 U0 = 0
 T = 0.7
 v = 1
 # Define video formats and libraries
 codecs = dict(flv='flv', mp4='libx264', webm='libvpx',
               ogg='libtheora')
 # Remove video files
 import glob, os
 for name in glob.glob('tmp_*.png'):
     os.remove(name)
 for ext in codecs:
     name = 'movie.%s' % ext
     if os.path.isfile(name):
         os.remove(name)
integral = solver(
    I=I, U0=U0, v=v, L=L, dt=dt, C=C, T=T,
    scheme=scheme, user_action=plot)
 # Finish up figure(2)
 plt.figure(2)
 plt.axis([0, L, -0.5, 1.1])
 plt.xlabel('$x$'); plt.ylabel('$u$')
 plt.savefig('tmp1.png'); plt.savefig('tmp1.pdf')
 plt.show()
 # Make videos from figure(1) animation files
 for codec in codecs:
     cmd = 'ffmpeg -i tmp_%%04d.png -r 25 -vcodec %s movie.%s' % \
           (codecs[codec], codec)
     os.system(cmd)
 print 'Integral of u:', integral.max(), integral.min()
```
The complete code is found in the file advec1D.py.

#### **4.1.7 A Crank-Nicolson Discretization in Time and Centered Differences in Space**

Another obvious candidate for time discretization is the Crank-Nicolson method combined with centered differences in space:

$$[D\_t u]\_i^n + v \frac{1}{2} ([D\_{2\chi} u]\_i^{n+1} + [D\_{2\chi} u]\_i^n) = 0 \dots$$

It can be nice to include the Backward Euler scheme too, via the --rule,

$$[D\_t u]\_i^n + \upsilon \theta [D\_{2\chi} u]\_i^{n+1} + \upsilon (1 - \theta) [D\_{2\chi} u]\_i^n = 0 \ .$$

When is different from zero, this gives rise to an *implicit* scheme,

$$u\_{i}^{n+1} + \frac{\theta}{2}C(u\_{i+1}^{n+1} - u\_{i-1}^{n+1}) = u\_{i}^{n} - \frac{1-\theta}{2}C(u\_{i+1}^{n} - u\_{i-1}^{n})$$

**Fig. 4.6** Crank-Nicolson in time, centered in space, Gaussian profile, <sup>C</sup> D 0:8, t D 0:01 (*left*) and t D 0:005 (*right*)

for <sup>i</sup> <sup>D</sup> 1; : : : ; Nx <sup>1</sup>. At the boundaries we set <sup>u</sup> <sup>D</sup> <sup>0</sup> and simulate just to the point of time when the signal hits the boundary (and gets reflected).

$$
u\_0^{n+1} = 
u\_{N\_x}^{n+1} = 0 \, .
$$

The elements on the diagonal in the matrix become:

$$A\_{i,i} = 1, \quad i = 0, \ldots, N\_{\ge} \ldots$$

On the subdiagonal and superdiagonal we have

$$A\_{i-1,i} = -\frac{\theta}{2}C, \quad A\_{i+1,i} = \frac{\theta}{2}C, \quad i = 1, \ldots, N\_{\times} - 1,$$

with A0;1 <sup>D</sup> <sup>0</sup> and ANx1;Nx <sup>D</sup> <sup>0</sup> due to the known boundary conditions. And finally, the right-hand side becomes

$$\begin{aligned} b\_0 &= u\_{N\_\chi}^n \\ b\_i &= u\_i^n - \frac{1-\theta}{2}C(u\_{i+1}^n - u\_{i-1}^n), \quad i = 1, \dots, N\_\chi - 1 \\ b\_{N\_\chi} &= u\_0^n. \end{aligned}$$

The dispersion relation follows from inserting u<sup>n</sup> <sup>q</sup> <sup>D</sup> Aneikx and using the formula (A.25) for the spatial differences:

$$A = \frac{1 - (1 - \theta)iC \sin p}{1 + \theta iC \sin p}.$$

**Movie 1** Crank-Nicolson in time, centered in space, <sup>C</sup> D 0:8, t D 0:005. https://raw.githubusercontent.com/hplgit/fdm-book/master/doc/pub/book/html/mov-advec/ gaussian/CN/C08\_dt0005/movie.ogg

**Movie 2** Backward-Euler in time, centered in space, <sup>C</sup> D 0:8, t D 0:005. https://raw.githubusercontent.com/hplgit/fdm-book/master/doc/pub/book/html/mov-advec/ cosinehat/BE/C\_08\_dt005.ogg

**Fig. 4.7** Backward-Euler in time, centered in space, half a cosine profile, <sup>C</sup> D 0:8, t D 0:01 (*left*) and t D 0:005 (*right*)

Figure 4.6 depicts a numerical solution for <sup>C</sup> D 0:8 and the Crank-Nicolson with severe oscillations behind the main wave. These oscillations are damped as the mesh is refined. Switching to the Backward Euler scheme removes the oscillations, but the amplitude is significantly reduced. One could expect that the discontinuous derivative in the initial condition of the half a cosine wave would make even stronger demands on producing a smooth profile, but Fig. 4.7 shows that also here, Backward-Euler is capable of producing a smooth profile. All in all, there are no major differences between the Gaussian initial condition and the half a cosine condition for any of the schemes.

#### **4.1.8 The Lax-Wendroff Method**

The Lax-Wendroff method is based on three ideas:


Let us follow the recipe. First we have the three-term Taylor polynomial,

$$u\_i^{n+1} = u\_i^n + \Delta t \left(\frac{\partial u}{\partial t}\right)\_i^n + \frac{1}{2} \Delta t^2 \left(\frac{\partial^2 u}{\partial t^2}\right)\_i^n \dots$$

From the PDE we have that temporal derivatives can be substituted by spatial derivatives:

$$\frac{\partial u}{\partial t} = -v \frac{\partial u}{\partial x},$$

and furthermore,

$$\frac{\partial^2 u}{\partial t^2} = v^2 \frac{\partial^2 u}{\partial x^2}.$$

Inserted in the Taylor polynomial formula, we get

$$u\_i^{n+1} = u\_i^n - v \Delta t \left(\frac{\partial u}{\partial x}\right)\_i^n + \frac{1}{2} \Delta t^2 v^2 \left(\frac{\partial^2 u}{\partial x^2}\right)\_i^n \dots$$

To obtain second-order accuracy in space we now use central differences:

$$u\_i^{n+1} = u\_i^n - v \Delta t [D\_{2\chi} u]\_i^n + \frac{1}{2} \Delta t^2 v^2 [D\_{\chi} D\_{\chi} u]\_i^n,$$

or written out,

$$u\_{i}^{n+1} = u\_{i}^{n} - \frac{1}{2}C(u\_{i+1}^{n} - u\_{i-1}^{n}) + \frac{1}{2}C^{2}(u\_{i+1}^{n} - 2u\_{i}^{n} + u\_{i-1}^{n}) \dots$$

This is the explicit Lax-Wendroff scheme.

#### **Lax-Wendroff works because of artificial viscosity**

From the formulas above, we notice that the Lax-Wendroff method is nothing but a Forward Euler, central difference in space scheme, which we have shown to be useless because of chronic instability, plus an artificial diffusion term of strength <sup>1</sup> <sup>2</sup>tv<sup>2</sup>. It means that we can take an unstable scheme and add some diffusion to stabilize it. This is a common trick to deal with advection problems. Sometimes, the real physical diffusion is not sufficiently large to make schemes stable, so then we also add artificial diffusion.

From an analysis similar to the ones carried out above, we get an amplification factor for the Lax-Wendroff method that equals

$$A = 1 - iC\sin p - 2C^2\sin^2(p/2) \, .$$

This means that jAj D <sup>1</sup> and also that we have an exact solution if <sup>C</sup> D <sup>1</sup>!

#### **4.1.9 Analysis of Dispersion Relations**

We have developed expressions for A.C; p/ in the exact solution u<sup>n</sup> <sup>q</sup> <sup>D</sup> Aneikqx of the discrete equations. Note that the Fourier component that solves the original PDE problem has no damping and moves with constant velocity v. There are two basic errors in the numerical Fourier component: there may be damping and the wave velocity may depend on <sup>C</sup> and <sup>p</sup> D kx.

The shortest wavelength that can be represented is D 2x. The corresponding <sup>k</sup> is <sup>k</sup> D 2= D =x, so <sup>p</sup> D kx 2 .0; .

Given a complex A as a function of C and p, how can we visualize it? The two key ingredients in A is the magnitude, reflecting damping or growth of the wave, and the angle, closely related to the velocity of the wave. The Fourier component

$$D^n e^{ik(x-ct)}$$

**Fig. 4.8** Dispersion relations for <sup>C</sup> D <sup>1</sup>

has damping <sup>D</sup> and wave velocity <sup>c</sup>. Let us express our <sup>A</sup> in polar form, <sup>A</sup> D Arei, and insert this expression in our discrete component u<sup>n</sup> <sup>q</sup> <sup>D</sup> Aneikqx <sup>D</sup> Aneikx:

$$u\_q^n = A\_r^n e^{-i\phi n} e^{ik\cdot x} = A\_r^n e^{i(k\cdot x - n\phi)} = A\_r^n e^{i(k(\times - ct))},$$

<sup>c</sup> D kt :

for

$$\text{The first-order coupling between the two-dimensional } \mathcal{N} \text{-matrices is the only possible } \mathcal{N} \text{-matrices with } \mathcal{N} = \{0, 1, 2, \dots, N\} \text{ and } \mathcal{N} = \{0, 1, 2, \dots, N\}.$$

Now,

$$k\Delta t = \frac{Ck\Delta x}{v} = \frac{Cp}{v},$$

$$c = \frac{\phi v}{Cp}.$$

Cp :

so

An appropriate dimensionless quantity to plot is the scaled wave velocity c=v:

$$\frac{c}{v} = \frac{\phi}{C\_P} \, .$$

Figures 4.8–4.13 contain dispersion curves, velocity and damping, for various values of C. The horizontal axis shows the dimensionless frequency p of the wave, while the figures to the left illustrate the error in wave velocity c=v (should ideally be 1 for all p), and the figures to the right display the absolute value (magnitude) of the damping factor Ar . The curves are labeled according to the table below.


**Fig. 4.9** Dispersion relations for <sup>C</sup> D <sup>1</sup>

**Fig. 4.10** Dispersion relations for <sup>C</sup> D 0:8

**Fig. 4.11** Dispersion relations for <sup>C</sup> D 0:8

The total damping after some time <sup>T</sup> <sup>D</sup> nt is reflected by Ar .C; p/n. Since normally Ar < 1, the damping goes like A1=t <sup>r</sup> and approaches zero as t ! <sup>0</sup>. The only way to reduce damping is to increase C and/or the mesh resolution.

We can learn a lot from the dispersion relation plots. For example, looking at the plots for <sup>C</sup> D <sup>1</sup>, the schemes LW, UP, and LF has no amplitude reduction, but LF has wrong phase velocity for the shortest wave in the mesh. This wave does not

**Fig. 4.12** Dispersion relations for <sup>C</sup> D 0:5

**Fig. 4.13** Dispersion relations for <sup>C</sup> D 0:5

(normally) have enough amplitude to be seen, so for all practical purposes, there is no damping or wrong velocity of the individual waves, so the total shape of the wave is also correct. For the CN scheme, see Fig. 4.6, each individual wave has its amplitude, but they move with different velocities, so after a while, we see some of these waves lagging behind. For the BE scheme, see Fig. 4.7, all the shorter waves are so heavily dampened that we cannot see them after a while. We see only the longest waves, which have slightly wrong velocity, but visible amplitudes are sufficiently equal to produce what looks like a smooth profile.

Another feature was that the Leapfrog method produced oscillations, while the upwind scheme did not. Since the Leapfrog method does not dampen the shorter waves, which have wrong wave velocities of order 10 percent, we can see these waves as noise. The upwind scheme, however, dampens these waves. The same effect is also present in the Lax-Wendroff scheme, but the damping of the intermediate waves is hardly present, so there is visible noise in the total signal.

We realize that, compared to pure truncation error analysis, dispersion analysis sheds more light on the behavior of the computational schemes. Truncation analysis just says that Lax-Wendroff is better than upwind, because of the increased order in time, but most people would say upwind is the better one when looking at the plots.

#### **4.2 One-Dimensional Stationary Advection-Diffusion Equation**

Now we pay attention to a physical process where advection (or convection) is in balance with diffusion:

$$
v \frac{du}{dx} = \alpha \frac{d^2 u}{dx^2}.\tag{4.11}$$

For simplicity, we assume v and ˛ to be constant, but the extension to the variablecoefficient case is trivial. This equation can be viewed as the stationary limit of the corresponding time-dependent problem

$$\frac{\partial u}{\partial t} + v \frac{\partial u}{\partial x} = \alpha \frac{\partial^2 u}{\partial x^2}. \tag{4.12}$$

Equations of the form (4.11) or (4.12) arise from transport phenomena, either mass or heat transport. One can also view the equations as a simple model problem for the Navier-Stokes equations. With the chosen boundary conditions, the differential equation problem models the phenomenon of a *boundary layer*, where the solution changes rapidly very close to the boundary. This is a characteristic of many fluid flow problems, which makes strong demands to numerical methods. The fundamental numerical difficulty is related to non-physical oscillations of the solution (instability) if the first-derivative spatial term dominates over the second-derivative term.

#### **4.2.1 A Simple Model Problem**

We consider (4.11) on Œ0; L equipped with the boundary conditions u.0/ D U0, u.L/ D UL. By scaling we can reduce the number of parameters in the problem. We scale <sup>x</sup> by <sup>x</sup>N D x=L, and <sup>u</sup> by

$$
\bar{u} = \frac{u - U\_0}{U\_L - U\_0} \cdot
$$

Inserted in the governing equation we get

$$\frac{w(U\_L - U\_0)}{L} \frac{d\bar{u}}{d\bar{x}} = \frac{\alpha(U\_L - U\_0)}{L^2} \frac{d^2\bar{u}}{d\bar{x}^2}, \quad \bar{u}(0) = 0, \ \bar{u}(1) = 1\ .$$

Dropping the bars is common. We can then simplify to

$$\frac{du}{dx} = \epsilon \frac{d^2 u}{dx^2}, \quad u(0) = 0, \ u(1) = 1 \,. \tag{4.13}$$

There are two competing effects in this equation: the advection term transports signals to the right, while the diffusion term transports signals to the left and the right. The value u.0/ D <sup>0</sup> is transported through the domain if is small, and <sup>u</sup> <sup>0</sup> except in the vicinity of <sup>x</sup> D <sup>1</sup>, where u.1/ D <sup>1</sup> and the diffusion transports some information about u.1/ D <sup>1</sup> to the left. For large , diffusion dominates and the u takes on the "average" value, i.e., u gets a linear variation from 0 to 1 throughout the domain.

It turns out that we can find an exact solution to the differential equation problem and also to many of its discretizations. This is one reason why this model problem has been so successful in designing and investigating numerical methods for mixed convection/advection and diffusion. The exact solution reads

$$u\_{\mathfrak{C}}(x) = \frac{e^{x/\epsilon} - 1}{e^{1/\epsilon} - 1} \cdot \mathfrak{c}$$

The forthcoming plots illustrate this function for various values of .

#### **4.2.2 A Centered Finite Difference Scheme**

The most obvious idea to solve (4.13) is to apply centered differences:

$$[D\_{2\times}u = \epsilon D\_x D\_x u]\_{\delta}$$

for <sup>i</sup> <sup>D</sup> 1; : : : ; Nx 1, with u0 <sup>D</sup> <sup>0</sup> and uNx <sup>D</sup> <sup>1</sup>. Note that this is a coupled system of algebraic equations involving u0;:::;uNx .

Written out, the scheme becomes a tridiagonal system

$$A\_{i-1,i}u\_{i-1} + A\_{i,i}u\_i + A\_{i+1,i}u\_{i+1} = 0,$$

for <sup>i</sup> <sup>D</sup> 1; : : : ; Nx <sup>1</sup>

$$\begin{aligned} A\_{0,0} &= 1, \\ A\_{i-1,i} &= -\frac{1}{\Delta x} - \epsilon \frac{1}{\Delta x^2}, \\ A\_{i,i} &= 2\epsilon \frac{1}{\Delta x^2}, \\ A\_{i,i+1} &= \frac{1}{\Delta x} - \epsilon \frac{1}{\Delta x^2}, \\ A\_{N\_{\mathcal{X}}, N\_{\mathcal{X}}} &= 1. \end{aligned}$$

The right-hand side of the linear system is zero except bNx <sup>D</sup> <sup>1</sup>.

Figure 4.14 shows reasonably accurate results with Nx <sup>D</sup> <sup>20</sup> and Nx <sup>D</sup> <sup>40</sup> cells in <sup>x</sup> direction and a value of D 0:1. Decreasing to 0:01 leads to oscillatory solutions as depicted in Fig. 4.15. This is, unfortunately, a typical phenomenon in this type of problem: non-physical oscillations arise for small unless the resolution Nx is big enough. Exercise 4.1 develops a precise criterion: u is oscillation-free if

$$
\Delta x \le \frac{2}{\epsilon} \, .
$$

If we take the present model as a simplified model for a *viscous boundary layer* in real, industrial fluid flow applications, <sup>10</sup><sup>6</sup> and millions of cells are required to resolve the boundary layer. Fortunately, this is not strictly necessary as we have methods in the next section to overcome the problem!

**Fig. 4.14** Comparison of exact and numerical solution for <sup>D</sup> 0:1 and Nx <sup>D</sup> 20; 40 with centered differences

**Fig. 4.15** Comparison of exact and numerical solution for <sup>D</sup> 0:01 and Nx <sup>D</sup> 20; 40 with centered differences

#### **Solver**

A suitable solver for doing the experiments is presented below.

```
import numpy as np
def solver(eps, Nx, method='centered'):
   """
   Solver for the two point boundary value problem u'=eps*u'',
   u(0)=0, u(1)=1.
   """
   x = np.linspace(0, 1, Nx+1) # Mesh points in space
   # Make sure dx and dt are compatible with x and t
   dx = x[1] - x[0]
   u = np.zeros(Nx+1)
   # Representation of sparse matrix and right-hand side
   diagonal = np.zeros(Nx+1)
   lower = np.zeros(Nx)
   upper = np.zeros(Nx)
   b = np.zeros(Nx+1)
```

```
# Precompute sparse matrix (scipy format)
if method == 'centered':
    diagonal[:] = 2*eps/dx**2
    lower[:] = -1/dx - eps/dx**2
    upper[:] = 1/dx - eps/dx**2
elif method == 'upwind':
    diagonal[:] = 1/dx + 2*eps/dx**2
    lower[:] = 1/dx - eps/dx**2
    upper[:] = - eps/dx**2
# Insert boundary conditions
upper[0] = 0
lower[-1] = 0
diagonal[0] = diagonal[-1] = 1
b[-1] = 1.0
# Set up sparse matrix and solve
diags = [0, -1, 1]
import scipy.sparse
import scipy.sparse.linalg
A = scipy.sparse.diags(
    diagonals=[diagonal, lower, upper],
    offsets=[0, -1, 1], shape=(Nx+1, Nx+1),
    format='csr')
u[:] = scipy.sparse.linalg.spsolve(A, b)
return u, x
```
#### **4.2.3 Remedy: Upwind Finite Difference Scheme**

The scheme can be stabilized by letting the advective transport term, which is the dominating term, collect its information in the flow direction, i.e., upstream or upwind of the point in question. So, instead of using a centered difference

$$\frac{du}{dx\_i} \approx \frac{u\_{i+1} - u\_{i-1}}{2\Delta x},$$

we use the one-sided *upwind* difference

$$\frac{du}{dx}\_i \approx \frac{u\_i - u\_{i-1}}{\Delta x}\_i$$

in case v>0. For v<0 we set

$$
\frac{du}{dx\_i} \approx \frac{u\_{i+1} - u\_i}{\Delta x},
$$

On compact operator notation form, our upwind scheme can be expressed as

$$[D\_\chi^- u = \epsilon D\_x D\_x u]\_{\bar{t}}$$

provided v>0 (and >0).

We write out the equations and implement them as shown in the program in Sect. 4.2.2. The results appear in Fig. 4.16 and 4.17: no more oscillations!

**Fig. 4.16** Comparison of exact and numerical solution for <sup>D</sup> 0:1 and Nx <sup>D</sup> 20; 40 with upwind difference

**Fig. 4.17** Comparison of exact and numerical solution for <sup>D</sup> 0:01 and Nx <sup>D</sup> 20; 40 with upwind difference

We see that the upwind scheme is always stable, but it gives a thicker boundary layer when the centered scheme is also stable. Why the upwind scheme is always stable is easy to understand as soon as we undertake the mathematical analysis in Exercise 4.1. Moreover, the thicker layer (seemingly larger diffusion) can be understood by doing Exercise 4.2.

#### **Exact solution for this model problem**

It turns out that one can introduce a linear combination of the centered and upwind differences for the first-derivative term in this model problem. One can then adjust the weight in the linear combination so that the numerical solution becomes identical to the analytical solution of the differential equation problem at any mesh point.

#### **4.3 Time-dependent Convection-Diffusion Equations**

Now it is time to combine time-dependency, convection (advection) and diffusion into one equation:

$$\frac{\partial u}{\partial t} + v \frac{\partial u}{\partial x} = \alpha \frac{\partial^2 u}{\partial x^2} \,. \tag{4.14}$$

**Analytical insight** The diffusion is now dominated by convection, a wave, and diffusion, a loss of amplitude. One possible analytical solution is a traveling Gaussian function

$$u(\mathbf{x}, t) = B \exp\left(-\left(\frac{\mathbf{x} - vt}{4at}\right)\right).$$

This function moves with velocity v>0 to the right (v<0 to the left) due to convection, but at the same time we have a damping e16a<sup>2</sup> <sup>t</sup> <sup>2</sup> from diffusion.

#### **4.3.1 Forward in Time, Centered in Space Scheme**

The Forward Euler for the diffusion equation is a successful scheme, but it has a very strict stability condition. The similar Forward in time, centered in space strategy always gives unstable solutions for the advection PDE. What happens when we have both diffusion and advection present at once?

$$[D\_t u + v D\_{2\chi} u = \alpha D\_{\chi} D\_{\chi} u + f]\_i^n \dots$$

We expect that diffusion will stabilize the scheme, but that advection will destabilize it.

Another problem is non-physical oscillations, but not growing amplitudes, due to centered differences in the advection term. There will hence be two types of instabilities to consider. Our analysis showed that pure advection with centered differences in space needs some artificial diffusion to become stable (and then it produces upwind differences for the advection term). Adding more physical diffusion should further help the numerics to stabilize the non-physical oscillations.

The scheme is quickly implemented, but suffers from the need for small space and time steps, according to this reasoning. A better approach is to get rid of the non-physical oscillations in space by simply applying an upwind difference on the advection term.

#### **4.3.2 Forward in Time, Upwind in Space Scheme**

A good approximation for the pure advection equation is to use upwind discretization of the advection term. We also know that centered differences are good for the diffusion term, so let us combine these two discretizations:

$$
\alpha \left[ D\_l u + v D\_\chi^- u = \alpha D\_\chi D\_\chi u + f \right]\_l^n,\tag{4.15}
$$

for v>0. Use vDCu if v<0. In this case the physical diffusion and the extra numerical diffusion vx=2 will stabilize the solution, but give an overall too large reduction in amplitude compared with the exact solution.

We may also interpret the upwind difference as artificial numerical diffusion and centered differences in space everywhere, so the scheme can be expressed as

$$\left[D\_{\nu}u + vD\_{2\chi}^{-}u = a\frac{v\Delta x}{2}D\_{\chi}D\_{\chi}u + f\right]\_{i}^{n}.\tag{4.16}$$

#### **4.4 Applications of Advection Equations**

There are two major areas where advection and convection applications arise: transport of a substance and heat transport *in a fluid*. To derive the models, we may look at the similar derivations of diffusion models in Sect. 3.8, but change the assumption from a solid to fluid medium. This gives rise to the extra advection or convection term v ru. We briefly show how this is done.

Normally, transport in a fluid is dominated by the fluid flow and not diffusion, so we can neglect diffusion compared to advection or convection. The end result is anyway an equation of the form

$$\frac{\partial u}{\partial t} + \boldsymbol{\nu} \cdot \nabla u = 0 \,\,\,\theta$$

#### **4.4.1 Transport of a Substance**

The diffusion of a substance in Sect. 3.8.1 takes place in a solid medium, but in a fluid we can have two transport mechanisms: one by diffusion and one by advection. The latter arises from the fact that the substance particles are moved with the fluid velocity v such that the effective flux now consists of two and not only one component as in (3.121):

$$q = -\alpha \nabla c + \mathfrak{w}c \ . $$

Inserted in the equation r q D <sup>0</sup> we get the extra advection term r .vc/. Very often we deal with incompressible flows, r v D <sup>0</sup> such that the advective term becomes v rc. The mass transport equation for a substance then reads

$$\frac{\partial c}{\partial t} + \mathbf{v} \cdot \nabla c = \alpha \nabla^2 c \,. \tag{4.17}$$

#### **4.4.2 Transport of Heat in Fluids**

The derivation of the heat equation in Sect. 3.8.2 is limited to heat transport in solid bodies. If we turn the attention to heat transport in fluids, we get a material derivative of the internal energy in (3.123),

$$\frac{De}{dt} = -\nabla \cdot \boldsymbol{q},$$

and more terms if work by stresses is also included, where

$$\frac{De}{dt} = \frac{\partial e}{\partial t} + \mathbf{v} \cdot \nabla e,$$

v being the velocity of the fluid. The convective term vr<sup>e</sup> must therefore be added to the governing equation, resulting typically in

$$
\varrho c \left( \frac{\partial T}{\partial t} + \mathfrak{v} \cdot \nabla T \right) = \nabla \cdot (k \nabla T) + f,\tag{4.18}
$$

where f is some external heating inside the medium.

#### **4.5 Exercises**

#### **Exercise 4.1: Analyze 1D stationary convection-diffusion problem**

Explain the observations in the numerical experiments from Sect. 4.2.2 and 4.2.3 by finding exact numerical solutions.

*Hint* The difference equations allow solutions on the form Ai , where A is an unknown constant and i is a mesh point counter. There are two solutions for A, so the general solution is a linear combination of the two, where the constants in the linear combination are determined from the boundary conditions. Filename: twopt\_BVP\_analysis1.

#### **Exercise 4.2: Interpret upwind difference as artificial diffusion**

Consider an upwind, one-sided difference approximation to a term du=dx in a differential equation. Show that this formula can be expressed as a centered difference plus an artificial diffusion term of strength proportional to x. This means that introducing an upwind difference also means introducing extra diffusion of order *O*.x/.

Filename: twopt\_BVP\_analysis2.

**Open Access** This chapter is licensed under the terms of the Creative Commons Attribution 4.0 International License (http://creativecommons.org/licenses/by/4.0/), which permits use, sharing, adaptation, distribution and reproduction in any medium or format, as long as you give appropriate credit to the original author(s) and the source, provide a link to the Creative Commons license and indicate if changes were made.

The images or other third party material in this chapter are included in the chapter's Creative Commons license, unless indicated otherwise in a credit line to the material. If material is not included in the chapter's Creative Commons license and your intended use is not permitted by statutory regulation or exceeds the permitted use, you will need to obtain permission directly from the copyright holder.

# **5 Nonlinear Problems**

#### **5.1 Introduction of Basic Concepts**

#### **5.1.1 Linear Versus Nonlinear Equations**

**Algebraic equations** A linear, scalar, algebraic equation in x has the form

$$ax + b = 0,$$

for arbitrary real constants a and b. The unknown is a number x. All other algebraic equations, e.g., <sup>x</sup><sup>2</sup> C ax C <sup>b</sup> D <sup>0</sup>, are nonlinear. The typical feature in a nonlinear algebraic equation is that the unknown appears in products with itself, like x<sup>2</sup> or <sup>e</sup><sup>x</sup> D <sup>1</sup> C <sup>x</sup> C <sup>1</sup> <sup>2</sup>x<sup>2</sup> C <sup>1</sup> 3Šx<sup>3</sup> C :::

We know how to solve a linear algebraic equation, <sup>x</sup> D b=a, but there are no general methods for finding the exact solutions of nonlinear algebraic equations, except for very special cases (quadratic equations constitute a primary example). A nonlinear algebraic equation may have no solution, one solution, or many solutions. The tools for solving nonlinear algebraic equations are *iterative methods*, where we construct a series of linear equations, which we know how to solve, and hope that the solutions of the linear equations converge to a solution of the nonlinear equation we want to solve. Typical methods for nonlinear algebraic equation equations are Newton's method, the Bisection method, and the Secant method.

**Differential equations** The unknown in a differential equation is a function and not a number. In a linear differential equation, all terms involving the unknown function are linear in the unknown function or its derivatives. Linear here means that the unknown function, or a derivative of it, is multiplied by a number or a known function. All other differential equations are non-linear.

The easiest way to see if an equation is nonlinear, is to spot nonlinear terms where the unknown function or its derivatives are multiplied by each other. For example, in

$$u'(t) = -a(t)u(t) + b(t),$$

the terms involving the unknown function u are linear: u<sup>0</sup> contains the derivative of the unknown function multiplied by unity, and au contains the unknown function multiplied by a known function. However,

$$u'(t) = u(t)(1 - u(t)),$$

is nonlinear because of the term u<sup>2</sup> where the unknown function is multiplied by itself. Also

$$\frac{\partial u}{\partial t} + u \frac{\partial u}{\partial x} = 0,$$

is nonlinear because of the term uux where the unknown function appears in a product with its derivative. (Note here that we use different notations for derivatives: u<sup>0</sup> or du=dt for a function u.t/ of one variable, @u @t or ut for a function of more than one variable.)

Another example of a nonlinear equation is

$$u'' + \sin(u) = 0,$$

because sin.u/ contains products of u, which becomes clear if we expand the function in a Taylor series:

$$\sin(u) = u - \frac{1}{3}u^3 + \dots$$

#### **Mathematical proof of linearity**

To really prove mathematically that some differential equation in an unknown u is linear, show for each term T .u/that with <sup>u</sup> <sup>D</sup> au1 <sup>C</sup> bu2 for constants <sup>a</sup> and <sup>b</sup>,

$$T(au\_1 + bu\_2) = aT(u\_1) + bT(u\_2) \, .$$

For example, the term T .u/ D .sin<sup>2</sup> t/u<sup>0</sup> .t/ is linear because

$$\begin{aligned} T(au\_1 + bu\_2) &= (\sin^2 t)(au\_1(t) + bu\_2(t)) \\ &= a(\sin^2 t)u\_1(t) + b(\sin^2 t)u\_2(t) \\ &= aT(u\_1) + bT(u\_2) \end{aligned}$$

However, T .u/ D sin <sup>u</sup> is nonlinear because

$$T(au\_1 + bu\_2) = \sin(au\_1 + bu\_2) \neq a\sin u\_1 + b\sin u\_2 \dots$$

#### **5.1.2 A Simple Model Problem**

A series of forthcoming examples will explain how to tackle nonlinear differential equations with various techniques. We start with the (scaled) logistic equation as model problem:

$$u'(t) = u(t)(1 - u(t))\,. \tag{5.1}$$

This is a nonlinear ordinary differential equation (ODE) which will be solved by different strategies in the following. Depending on the chosen time discretization of (5.1), the mathematical problem to be solved at every time level will either be a linear algebraic equation or a nonlinear algebraic equation. In the former case, the time discretization method transforms the nonlinear ODE into linear subproblems at each time level, and the solution is straightforward to find since linear algebraic equations are easy to solve. However, when the time discretization leads to nonlinear algebraic equations, we cannot (except in very rare cases) solve these without turning to approximate, iterative solution methods.

The next subsections introduce various methods for solving nonlinear differential equations, using (5.1) as model. We shall go through the following set of cases:

	- **–** an exact analytical technique
	- **–** Picard iteration based on manual linearization
	- **–** a single Picard step
	- **–** Newton's method

Thereafter, we compare the performance of the various approaches. Despite the simplicity of (5.1), the conclusions reveal typical features of the various methods in much more complicated nonlinear PDE problems.

#### **5.1.3 Linearization by Explicit Time Discretization**

Time discretization methods are divided into explicit and implicit methods. Explicit methods lead to a closed-form formula for finding new values of the unknowns, while implicit methods give a linear or nonlinear system of equations that couples (all) the unknowns at a new time level. Here we shall demonstrate that explicit methods constitute an efficient way to deal with nonlinear differential equations.

The Forward Euler method is an explicit method. When applied to (5.1), sampled at <sup>t</sup> <sup>D</sup> tn, it results in

$$\frac{u^{n+1} - u^n}{\Delta t} = u^n (1 - u^n),$$

which is a *linear* algebraic equation for the unknown value u<sup>n</sup>C<sup>1</sup> that we can easily solve:

$$u^{n+1} = u^n + \Delta t \, u^n (1 - u^n) \, .$$

$$\begin{array}{c} \dots \ \ . \ \dots \ . \ . \end{array}$$

In this case, the nonlinearity in the original equation poses no difficulty in the discrete algebraic equation. Any other explicit scheme in time will also give only linear algebraic equations to solve. For example, a typical 2nd-order Runge-Kutta method for (5.1) leads to the following formulas:

$$\begin{aligned} u^\* &= u^n + \Delta t u^n (1 - u^n), \\ u^{n+1} &= u^n + \Delta t \frac{1}{2} \left( u^n (1 - u^n) + u^\* (1 - u^\*) \right) \ . \end{aligned}$$

The first step is linear in the unknown u . Then u is known in the next step, which is linear in the unknown u<sup>n</sup>C<sup>1</sup> .

#### **5.1.4 Exact Solution of Nonlinear Algebraic Equations**

Switching to a Backward Euler scheme for (5.1),

$$\frac{u^n - u^{n-1}}{\Delta t} = u^n (1 - u^n),\tag{5.2}$$

results in a nonlinear algebraic equation for the unknown value u<sup>n</sup>. The equation is of quadratic type:

$$
\Delta t (\boldsymbol{\mu}'')^2 + (1 - \Delta t) \boldsymbol{\mu}'' - \boldsymbol{\mu}^{n-1} = 0,
$$

and may be solved exactly by the well-known formula for such equations. Before we do so, however, we will introduce a shorter, and often cleaner, notation for nonlinear algebraic equations at a given time level. The notation is inspired by the natural notation (i.e., variable names) used in a program, especially in more advanced partial differential equation problems. The unknown in the algebraic equation is denoted by u, while u.1/ is the value of the unknown at the previous time level (in general, u.`/ is the value of the unknown ` levels back in time). The notation will be frequently used in later sections. What is meant by u should be evident from the context: u may either be 1) the exact solution of the ODE/PDE problem, 2) the numerical approximation to the exact solution, or 3) the unknown solution at a certain time level.

The quadratic equation for the unknown u<sup>n</sup> in (5.2) can, with the new notation, be written

$$F(u) = \Delta t u^2 + (1 - \Delta t)u - u^{(1)} = 0.\tag{5.3}$$

The solution is readily found to be

$$u = \frac{1}{2\Delta t} \left( -1 + \Delta t \pm \sqrt{(1 - \Delta t)^2 - 4\Delta t u^{(1)}} \right). \tag{5.4}$$

Now we encounter a fundamental challenge with nonlinear algebraic equations: the equation may have more than one solution. How do we pick the right solution? This is in general a hard problem. In the present simple case, however, we can analyze the roots mathematically and provide an answer. The idea is to expand the roots in a series in t and truncate after the linear term since the Backward Euler scheme will introduce an error proportional to t anyway. Using sympy, we find the following Taylor series expansions of the roots:

```
>>> import sympy as sym
>>> dt, u_1, u = sym.symbols('dt u_1 u')
>>> r1, r2 = sym.solve(dt*u**2 + (1-dt)*u - u_1, u) # find roots
>>> r1
(dt - sqrt(dt**2 + 4*dt*u_1 - 2*dt + 1) - 1)/(2*dt)
>>> r2
(dt + sqrt(dt**2 + 4*dt*u_1 - 2*dt + 1) - 1)/(2*dt)
>>> print r1.series(dt, 0, 2) # 2 terms in dt, around dt=0
>>> print r2.series(dt, 0, 2)
u_1 + dt*(-u_1**2 + u_1) + O(dt**2)
```
We see that the r1 root, corresponding to a minus sign in front of the square root in (5.4), behaves as 1=t and will therefore blow up as t ! <sup>0</sup>! Since we know that u takes on finite values, actually it is less than or equal to 1, only the r2 root is of relevance in this case: as t ! <sup>0</sup>, <sup>u</sup> ! <sup>u</sup>.1/, which is the expected result.

For those who are not well experienced with approximating mathematical formulas by series expansion, an alternative method of investigation is simply to compute the limits of the two roots as t ! <sup>0</sup> and see if a limit appears unreasonable:

```
>>> print r1.limit(dt, 0)
>>> print r2.limit(dt, 0)
u_1
```
#### **5.1.5 Linearization**

When the time integration of an ODE results in a nonlinear algebraic equation, we must normally find its solution by defining a sequence of linear equations and hope that the solutions of these linear equations converge to the desired solution of the nonlinear algebraic equation. Usually, this means solving the linear equation repeatedly in an iterative fashion. Alternatively, the nonlinear equation can sometimes be approximated by one linear equation, and consequently there is no need for iteration.

Constructing a linear equation from a nonlinear one requires *linearization* of each nonlinear term. This can be done manually as in Picard iteration, or fully algorithmically as in Newton's method. Examples will best illustrate how to linearize nonlinear problems.

#### **5.1.6 Picard Iteration**

Let us write (5.3) in a more compact form

$$F(u) = au^2 + bu + c = 0,$$

with <sup>a</sup> <sup>D</sup> t, <sup>b</sup> <sup>D</sup> <sup>1</sup> t, and <sup>c</sup> D u.1/. Let <sup>u</sup> be an available approximation of the unknown u. Then we can linearize the term u<sup>2</sup> simply by writing uu. The resulting equation, F .u/ <sup>O</sup> D <sup>0</sup>, is now linear and hence easy to solve:

$$F(u) \approx \ddot{F}(u) = au^-u + bu + c = 0 \dots$$

Since the equation <sup>F</sup><sup>O</sup> D <sup>0</sup> is only approximate, the solution <sup>u</sup> does not equal the exact solution <sup>u</sup><sup>e</sup> of the exact equation F .ue/ <sup>D</sup> <sup>0</sup>, but we can hope that <sup>u</sup> is closer to <sup>u</sup><sup>e</sup> than <sup>u</sup> is, and hence it makes sense to repeat the procedure, i.e., set <sup>u</sup> <sup>D</sup> <sup>u</sup> and solve F .u/ <sup>O</sup> <sup>D</sup> <sup>0</sup> again. There is no guarantee that <sup>u</sup> is closer to <sup>u</sup><sup>e</sup> than <sup>u</sup>, but this approach has proven to be effective in a wide range of applications.

The idea of turning a nonlinear equation into a linear one by using an approximation u of u in nonlinear terms is a widely used approach that goes under many names: *fixed-point iteration*, the method of *successive substitutions*, *nonlinear Richardson iteration*, and *Picard iteration*. We will stick to the latter name.

Picard iteration for solving the nonlinear equation arising from the Backward Euler discretization of the logistic equation can be written as

$$u = -\frac{c}{au^- + b}, \quad u^- \leftarrow u.$$

The symbols means assignment (we set <sup>u</sup> equal to the value of <sup>u</sup>). The iteration is started with the value of the unknown at the previous time level: <sup>u</sup> D <sup>u</sup>.1/.

Some prefer an explicit iteration counter as superscript in the mathematical notation. Let u<sup>k</sup> be the computed approximation to the solution in iteration k. In iteration <sup>k</sup> C <sup>1</sup> we want to solve

$$au^{k}u^{k+1} + bu^{k+1} + c = 0 \quad \Rightarrow \quad u^{k+1} = -\frac{c}{au^{k} + b}, \quad k = 0, 1, \dots$$

Since we need to perform the iteration at every time level, the time level counter is often also included:

$$au^{n,k}u^{n,k+1} + bu^{n,k+1} - u^{n-1} = 0 \quad \Rightarrow \quad u^{n,k+1} = \frac{u^n}{au^{n,k} + b}, \quad k = 0, 1, \dots, n$$

with the start value <sup>u</sup>n;0 <sup>D</sup> <sup>u</sup><sup>n</sup><sup>1</sup> and the final converged value <sup>u</sup><sup>n</sup> <sup>D</sup> <sup>u</sup>n;k for sufficiently large k.

However, we will normally apply a mathematical notation in our final formulas that is as close as possible to what we aim to write in a computer code and then it becomes natural to use u and u instead of u<sup>k</sup>C<sup>1</sup> and u<sup>k</sup> or un;kC<sup>1</sup> and un;k.

**Stopping criteria** The iteration method can typically be terminated when the change in the solution is smaller than a tolerance u:

$$|u - u^{-}| \le \epsilon\_u,$$

or when the residual in the equation is sufficiently small (< 
r ),

$$|F(u)| = |au^2 + bu + c| < \epsilon\_r \ . $$

**A single Picard iteration** Instead of iterating until a stopping criterion is fulfilled, one may iterate a specific number of times. Just one Picard iteration is popular as this corresponds to the intuitive idea of approximating a nonlinear term like .un/2 by <sup>u</sup><sup>n</sup>1un. This follows from the linearization <sup>u</sup>u<sup>n</sup> and the initial choice of <sup>u</sup> D u<sup>n</sup><sup>1</sup> at time level tn. In other words, a single Picard iteration corresponds to using the solution at the previous time level to linearize nonlinear terms. The resulting discretization becomes (using proper values for a, b, and c)

$$\frac{u^n - u^{n-1}}{\Delta t} = u^n (1 - u^{n-1}),\tag{5.5}$$

which is a linear algebraic equation in the unknown u<sup>n</sup>, making it easy to solve for u<sup>n</sup> without any need for an alternative notation.

We shall later refer to the strategy of taking one Picard step, or equivalently, linearizing terms with use of the solution at the previous time step, as the *Picard1* method. It is a widely used approach in science and technology, but with some limitations if t is not sufficiently small (as will be illustrated later).

#### **Notice**

Equation (5.5) does not correspond to a "pure" finite difference method where the equation is sampled at a point and derivatives replaced by differences (because the u<sup>n</sup><sup>1</sup> term on the right-hand side must then be u<sup>n</sup>). The best interpretation of the scheme (5.5) is a Backward Euler difference combined with a single (perhaps insufficient) Picard iteration at each time level, with the value at the previous time level as start for the Picard iteration.

#### **5.1.7 Linearization by a Geometric Mean**

We consider now a Crank-Nicolson discretization of (5.1). This means that the time derivative is approximated by a centered difference,

$$[D\_t u = u(1 - u)]^{n + \frac{1}{2}},$$

written out as

$$\frac{u^{n+1} - u^n}{\Delta t} = u^{n + \frac{1}{2}} - (u^{n + \frac{1}{2}})^2. \tag{5.6}$$

The term u<sup>n</sup><sup>C</sup> <sup>1</sup> <sup>2</sup> is normally approximated by an arithmetic mean,

$$
u^{n+\frac{1}{2}} \approx \frac{1}{2} (\boldsymbol{u}^n + \boldsymbol{u}^{n+1}),$$

such that the scheme involves the unknown function only at the time levels where we actually intend to compute it. The same arithmetic mean applied to the nonlinear term gives

$$(u^{n+\frac{1}{2}})^2 \approx \frac{1}{4}(u^n + u^{n+1})^2,$$

which is nonlinear in the unknown u<sup>n</sup>C<sup>1</sup>. However, using a *geometric mean* for .u<sup>n</sup>C <sup>1</sup> <sup>2</sup> /2 is a way of linearizing the nonlinear term in (5.6):

$$(u^{n+\frac{1}{2}})^2 \approx u^n u^{n+1} \dots$$

Using an arithmetic mean on the linear u<sup>n</sup><sup>C</sup> <sup>1</sup> <sup>2</sup> term in (5.6) and a geometric mean for the second term, results in a linearized equation for the unknown u<sup>n</sup>C<sup>1</sup>:

$$\frac{u^{n+1} - u^n}{\Delta t} = \frac{1}{2} (u^n + u^{n+1}) + u^n u^{n+1},$$

which can readily be solved:

$$u^{n+1} = \frac{1 + \frac{1}{2}\Delta t}{1 + \Delta t u^n - \frac{1}{2}\Delta t}u^n \ .$$

This scheme can be coded directly, and since there is no nonlinear algebraic equation to iterate over, we skip the simplified notation with u for u<sup>n</sup>C<sup>1</sup> and u.1/ for u<sup>n</sup>. The technique with using a geometric average is an example of transforming a nonlinear algebraic equation to a linear one, without any need for iterations.

The geometric mean approximation is often very effective for linearizing quadratic nonlinearities. Both the arithmetic and geometric mean approximations have truncation errors of order t <sup>2</sup> and are therefore compatible with the truncation error *<sup>O</sup>*.t 2/ of the centered difference approximation for u<sup>0</sup> in the Crank-Nicolson method.

Applying the operator notation for the means and finite differences, the linearized Crank-Nicolson scheme for the logistic equation can be compactly expressed as

$$\left[D\_{\iota}u = \overline{u}^{\iota} + \overline{u^2}^{\iota,\mathfrak{g}}\right]^{n + \frac{1}{2}}\dots$$

#### **Remark**

If we use an arithmetic instead of a geometric mean for the nonlinear term in (5.6), we end up with a nonlinear term .u<sup>n</sup>C1/2. This term can be linearized as uu<sup>n</sup>C<sup>1</sup> in a Picard iteration approach and in particular as ununC<sup>1</sup> in a Picard1 iteration approach. The latter gives a scheme almost identical to the one arising from a geometric mean (the difference in u<sup>n</sup>C<sup>1</sup> being <sup>1</sup> <sup>4</sup>tun.unC<sup>1</sup> <sup>u</sup>n/ <sup>1</sup> <sup>4</sup>t 2u<sup>0</sup> u, i.e., a difference of size t <sup>2</sup>).

#### **5.1.8 Newton's Method**

The Backward Euler scheme (5.2) for the logistic equation leads to a nonlinear algebraic equation (5.3). Now we write any nonlinear algebraic equation in the general and compact form

$$F(u) = 0.$$

Newton's method linearizes this equation by approximating F .u/ by its Taylor series expansion around a computed value u and keeping only the linear part:

$$\begin{aligned} F(u) &= F(u^-) + F'(u^-)(u - u^-) + \frac{1}{2}F''(u^-)(u - u^-)^2 + \cdots \\ &\approx F(u^-) + F'(u^-)(u - u^-) = \hat{F}(u) \,. \end{aligned}$$

The linear equation F .u/ <sup>O</sup> D <sup>0</sup> has the solution

$$u = u^- - \frac{F(u^-)}{F'(u^-)} \cdot$$

Expressed with an iteration index in the unknown, Newton's method takes on the more familiar mathematical form

$$u^{k+1} = u^k - \frac{F(u^k)}{F'(u^k)}, \quad k = 0, 1, \dots$$

It can be shown that the error in iteration <sup>k</sup> C <sup>1</sup> of Newton's method is proportional to the square of the error in iteration k, a result referred to as *quadratic convergence*. This means that for small errors the method converges very fast, and in particular much faster than Picard iteration and other iteration methods. (The proof of this result is found in most textbooks on numerical analysis.) However, the quadratic convergence appears only if u<sup>k</sup> is sufficiently close to the solution. Further away from the solution the method can easily converge very slowly or diverge. The reader is encouraged to do Exercise 5.3 to get a better understanding for the behavior of the method.

Application of Newton's method to the logistic equation discretized by the Backward Euler method is straightforward as we have

$$F(u) = au^2 + bu + c, \quad a = \Delta t, \; b = 1 - \Delta t, \; c = -u^{(1)},$$

and then

$$F'(u) = 2au + b\ .$$

The iteration method becomes

$$u = u^- + \frac{a(u^-)^2 + bu^- + c}{2au^- + b}, \quad u^- \leftarrow u \,. \tag{5.7}$$

At each time level, we start the iteration by setting <sup>u</sup> <sup>D</sup> <sup>u</sup>.1/. Stopping criteria as listed for the Picard iteration can be used also for Newton's method.

An alternative mathematical form, where we write out a, b, and c, and use a time level counter n and an iteration counter k, takes the form

$$u^{n,k+1} = u^{n,k} + \frac{\Delta t (u^{n,k})^2 + (1 - \Delta t)u^{n,k} - u^{n-1}}{2 \Delta t u^{n,k} + 1 - \Delta t}, \quad u^{n,0} = u^{n-1}, \qquad (5.8)$$

for <sup>k</sup> D 0; 1; : : :. A program implementation is much closer to (5.7) than to (5.8), but the latter is better aligned with the established mathematical notation used in the literature.

#### **5.1.9 Relaxation**

One iteration in Newton's method or Picard iteration consists of solving a linear problem F .u/ <sup>O</sup> D <sup>0</sup>. Sometimes convergence problems arise because the new solution <sup>u</sup> of F .u/ <sup>O</sup> D <sup>0</sup> is "too far away" from the previously computed solution <sup>u</sup>. A remedy is to introduce a relaxation, meaning that we first solve F .u <sup>O</sup> / D <sup>0</sup> for a suggested value u and then we take u as a weighted mean of what we had, u, and what our linearized equation <sup>F</sup><sup>O</sup> D <sup>0</sup> suggests, <sup>u</sup> :

$$u = \alpha u^\* + (1 - \alpha)u^- \, .$$

The parameter ! is known as a *relaxation parameter*, and a choice !<1 may prevent divergent iterations.

Relaxation in Newton's method can be directly incorporated in the basic iteration formula:

$$u = u^- - \alpha \frac{F(u^-)}{F'(u^-)}.\tag{5.9}$$

#### **5.1.10 Implementation and Experiments**

The program logistic.py contains implementations of all the methods described above. Below is an extract of the file showing how the Picard and Newton methods are implemented for a Backward Euler discretization of the logistic equation.

```
def BE_logistic(u0, dt, Nt, choice='Picard',
                eps_r=1E-3, omega=1, max_iter=1000):
    if choice == 'Picard1':
        choice = 'Picard'
        max_iter = 1
    u = np.zeros(Nt+1)
    iterations = []
    u[0] = u0
    for n in range(1, Nt+1):
        a = dt
        b =1- dt
        c = -u[n-1]
        if choice == 'Picard':
            def F(u):
                return a*u**2 + b*u + c
            u_ = u[n-1]
            k = 0
            while abs(F(u_)) > eps_r and k < max_iter:
                u_ = omega*(-c/(a*u_ + b)) + (1-omega)*u_
                k += 1
            u[n] = u_
            iterations.append(k)
        elif choice == 'Newton':
            def F(u):
                return a*u**2 + b*u + c
            def dF(u):
                return 2*a*u + b
            u_ = u[n-1]
            k = 0
            while abs(F(u_)) > eps_r and k < max_iter:
                u_ = u_ - F(u_)/dF(u_)
                k += 1
            u[n] = u_
            iterations.append(k)
    return u, iterations
```
The Crank-Nicolson method utilizing a linearization based on the geometric mean gives a simpler algorithm:

```
def CN_logistic(u0, dt, Nt):
    u = np.zeros(Nt+1)
    u[0] = u0
    for n in range(0, Nt):
        u[n+1] = (1 + 0.5*dt)/(1 + dt*u[n] - 0.5*dt)*u[n]
    return u
```
We may run experiments with the model problem (5.1) and the different strategies for dealing with nonlinearities as described above. For a quite coarse time resolution, t <sup>D</sup> 0:9, use of a tolerance <sup>r</sup> <sup>D</sup> 0:1 in the stopping criterion introduces an iteration error, especially in the Picard iterations, that is visibly much larger than the time discretization error due to a large t. This is illustrated by comparing the upper two plots in Fig. 5.1. The one to the right has a stricter tolerance <sup>D</sup> <sup>10</sup><sup>3</sup>, which causes all the curves corresponding to Picard and Newton iteration to be on top of each other (and no changes can be visually observed by reducing r further). The reason why Newton's method does much better than Picard iteration in the upper left plot is that Newton's method with one step comes far below the r tolerance, while the Picard iteration needs on average 7 iterations to bring the residual down to <sup>r</sup> <sup>D</sup> <sup>10</sup><sup>1</sup>, which gives insufficient accuracy in the solution of the nonlinear equation. It is obvious that the Picard1 method gives significant errors in

**Fig. 5.1** Impact of solution strategy and time step length on the solution

**Fig. 5.2** Comparison of the number of iterations at various time levels for Picard and Newton iteration

addition to the time discretization unless the time step is as small as in the lower right plot.

The *BE exact* curve corresponds to using the exact solution of the quadratic equation at each time level, so this curve is only affected by the Backward Euler time discretization. The *CN gm* curve corresponds to the theoretically more accurate Crank-Nicolson discretization, combined with a geometric mean for linearization. This curve appears more accurate, especially if we take the plot in the lower right with a small t and an appropriately small r value as the exact curve.

When it comes to the need for iterations, Fig. 5.2 displays the number of iterations required at each time level for Newton's method and Picard iteration. The smaller t is, the better starting value we have for the iteration, and the faster the convergence is. With t D 0:9 Picard iteration requires on average 32 iterations per time step, but this number is dramatically reduced as t is reduced.

However, introducing relaxation and a parameter ! D 0:8 immediately reduces the average of 32 to 7, indicating that for the large t D 0:9, Picard iteration takes too long steps. An approximately optimal value for ! in this case is 0.5, which results in an average of only 2 iterations! An even more dramatic impact of ! appears when t D <sup>1</sup>: Picard iteration does not convergence in 1000 iterations, but ! D 0:5 again brings the average number of iterations down to 2.

**Remark** The simple Crank-Nicolson method with a geometric mean for the quadratic nonlinearity gives visually more accurate solutions than the Backward Euler discretization. Even with a tolerance of <sup>r</sup> <sup>D</sup> <sup>10</sup><sup>3</sup>, all the methods for treating the nonlinearities in the Backward Euler discretization give graphs that cannot be distinguished. So for accuracy in this problem, the time discretization is much more crucial than r . Ideally, one should estimate the error in the time discretization, as the solution progresses, and set r accordingly.

#### **5.1.11 Generalization to a General Nonlinear ODE**

Let us see how the various methods in the previous sections can be applied to the more generic model

$$u' = f(u, t),\tag{5.10}$$

where f is a nonlinear function of u.

**Explicit time discretization** Explicit ODE methods like the Forward Euler scheme, Runge-Kutta methods and Adams-Bashforth methods all evaluate f at time levels where u is already computed, so nonlinearities in f do not pose any difficulties.

**Backward Euler discretization** Approximating u<sup>0</sup> by a backward difference leads to a Backward Euler scheme, which can be written as

$$F(u^n) = u^n - \Delta t \, f(u^n, t\_n) - u^{n-1} = 0,$$

or alternatively

$$F(u) = u - \Delta t \, f(u, t\_n) - u^{(1)} = 0 \, .$$

A simple Picard iteration, not knowing anything about the nonlinear structure of f , must approximate f .u; tn/ by f .u; tn/:

$$
\hat{F}(u) = u - \Delta t \left. f(u^-, t\_n) - u^{(1)} \right.
$$

The iteration starts with <sup>u</sup> D <sup>u</sup>.1/ and proceeds with repeating

$$u^\* = \Delta t \, f(u^-, t\_n) + u^{(1)}, \quad u = \omega u^\* + (1 - \omega)u^-, \quad u^- \leftarrow u, \omega$$

until a stopping criterion is fulfilled.

#### **Explicit vs implicit treatment of nonlinear terms**

Evaluating f for a known u is referred to as *explicit* treatment of f , while if f .u; t/ has some structure, say f .u; t/ D <sup>u</sup><sup>3</sup>, parts of <sup>f</sup> can involve the unknown u, as in the manual linearization .u/2u, and then the treatment of f is "more implicit" and "less explicit". This terminology is inspired by time discretization of <sup>u</sup><sup>0</sup> D f .u; t/, where evaluating <sup>f</sup> for known <sup>u</sup> values gives explicit schemes, while treating f or parts of f implicitly, makes f contribute to the unknown terms in the equation at the new time level.

Explicit treatment of f usually means stricter conditions on t to achieve stability of time discretization schemes. The same applies to iteration techniques for nonlinear algebraic equations: the "less" we linearize f (i.e., the more we keep of u in the original formula), the faster the convergence may be.

We may say that f .u; t/ D <sup>u</sup><sup>3</sup> is treated explicitly if we evaluate <sup>f</sup> as .u/3, partially implicit if we linearize as .u/2u and fully implicit if we represent f by u<sup>3</sup>. (Of course, the fully implicit representation will require further linearization, but with f .u; t/ D <sup>u</sup><sup>2</sup> a fully implicit treatment is possible if the resulting quadratic equation is solved with a formula.)

For the ODE <sup>u</sup><sup>0</sup> D u<sup>3</sup> with f .u; t/ D u<sup>3</sup> and coarse time resolution t <sup>D</sup> 0:4, Picard iteration with .u/2u requires 8 iterations with <sup>r</sup> <sup>D</sup> <sup>10</sup><sup>3</sup> for the first time step, while .u/3 leads to 22 iterations. After about 10 time steps both approaches are down to about 2 iterations per time step, but this example shows a potential of treating f more implicitly.

A trick to treat f implicitly in Picard iteration is to evaluate it as f .u; t/u=u. For a polynomial <sup>f</sup> , f .u; t/ D <sup>u</sup><sup>m</sup>, this corresponds to .u/mu=u D .u/m1u. Sometimes this more implicit treatment has no effect, as with f .u; t/ D exp.u/ and f .u; t/ D ln.1 C u/, but with f .u; t/ D sin.2.u C 1//, the f .u; t/u=u trick leads to 7, 9, and 11 iterations during the first three steps, while f .u;t/ demands 17, 21, and 20 iterations. (Experiments can be done with the code ODE\_Picard\_tricks.py.)

Newton's method applied to a Backward Euler discretization of <sup>u</sup><sup>0</sup> D f .u; t/ requires computation of the derivative

$$F'(u) = 1 - \Delta t \frac{\partial f}{\partial u}(u, t\_n) \dots$$

Starting with the solution at the previous time level, <sup>u</sup> <sup>D</sup> <sup>u</sup>.1/, we can just use the standard formula

$$u = u^- - \omega \frac{F(u^-)}{F'(u^-)} = u^- - \omega \frac{u^- - \Delta t \ f(u^-, t\_n) - u^{(1)}}{1 - \Delta t \frac{\partial}{\partial u} f(u^-, t\_n)}.\tag{5.11}$$

**Crank-Nicolson discretization** The standard Crank-Nicolson scheme with arithmetic mean approximation of f takes the form

$$\frac{u^{n+1} - u^n}{\Delta t} = \frac{1}{2} \left( f(u^{n+1}, t\_{n+1}) + f(u^n, t\_n) \right) \ .$$

We can write the scheme as a nonlinear algebraic equation

$$F(u) = u - u^{(1)} - \Delta t \frac{1}{2} f(u, t\_{n+1}) - \Delta t \frac{1}{2} f(u^{(1)}, t\_n) = 0 \,. \tag{5.12}$$

A Picard iteration scheme must in general employ the linearization

$$
\hat{F}(u) = u - u^{(1)} - \Delta t \frac{1}{2} f(u^-, t\_{n+1}) - \Delta t \frac{1}{2} f(u^{(1)}, t\_n),
$$

while Newton's method can apply the general formula (5.11) with F .u/ given in (5.12) and

$$F'(u) = 1 - \frac{1}{2} \Delta t \frac{\partial f}{\partial u}(u, t\_{n+1}) \dots$$

#### **5.1.12 Systems of ODEs**

We may write a system of ODEs

$$\begin{aligned} \frac{d}{dt}u\_0(t) &= f\_0(u\_0(t), u\_1(t), \dots, u\_N(t), t), \\ \frac{d}{dt}u\_1(t) &= f\_1(u\_0(t), u\_1(t), \dots, u\_N(t), t), \\ &\vdots \\ \frac{d}{dt}u\_m(t) &= f\_m(u\_0(t), u\_1(t), \dots, u\_N(t), t), \end{aligned}$$

as

$$u' = f(u, t), \quad u(0) = U\_0,\tag{5.13}$$

if we interpret <sup>u</sup> as a vector <sup>u</sup> <sup>D</sup> .u0.t/; u1.t/; : : : ; uN .t// and <sup>f</sup> as a vector function with components .f0.u; t/; f1.u; t/; : : : ; fN .u; t//.

Most solution methods for scalar ODEs, including the Forward and Backward Euler schemes and the Crank-Nicolson method, generalize in a straightforward way to systems of ODEs simply by using vector arithmetics instead of scalar arithmetics, which corresponds to applying the scalar scheme to each component of the system. For example, here is a backward difference scheme applied to each component,

$$\begin{aligned} \frac{u\_0^n - u\_0^{n-1}}{\Delta t} &= f\_0(u^n, t\_n), \\ \frac{u\_1^n - u\_1^{n-1}}{\Delta t} &= f\_1(u^n, t\_n), \\ &\vdots \\ \frac{u\_N^n - u\_N^{n-1}}{\Delta t} &= f\_N(u^n, t\_n), \end{aligned}$$

which can be written more compactly in vector form as

$$\frac{u^n - u^{n-1}}{\Delta t} = f(u^n, t\_n).$$

This is a *system of algebraic equations*,

$$u'' - \Delta t \, f(u'', t\_n) - u^{n-1} = 0,$$

or written out

$$\begin{aligned} u\_0^n - \Delta t \, f\_0(u^n, t\_n) - u\_0^{n-1} &= 0, \\ \vdots \\ u\_N^n - \Delta t \, f\_N(u^n, t\_n) - u\_N^{n-1} &= 0 \dots \end{aligned}$$

**Example** We shall address the <sup>2</sup> - 2 ODE system for oscillations of a pendulum subject to gravity and air drag. The system can be written as

$$
\dot{\phi} = -\sin\theta - \beta\alpha|\omega|,\tag{5.14}
$$

$$
\theta = \omega,\tag{5.15}
$$

where ˇ is a dimensionless parameter (this is the scaled, dimensionless version of the original, physical model). The unknown components of the system are the angle -.t/ and the angular velocity !.t/. We introduce u0 <sup>D</sup> ! and u1 <sup>D</sup> -, which leads to

$$\begin{aligned} u\_0' &= f\_0(\boldsymbol{\mu}, t) = -\sin \boldsymbol{\mu}\_1 - \beta \boldsymbol{\mu}\_0 |\boldsymbol{\mu}\_0|, \\ u\_1' &= f\_1(\boldsymbol{\mu}, t) = \boldsymbol{\mu}\_0 \,. \end{aligned}$$

A Crank-Nicolson scheme reads

$$\begin{split} \frac{u\_0^{n+1} - u\_0^n}{\Delta t} &= -\sin u\_1^{n+\frac{1}{2}} - \beta u\_0^{n+\frac{1}{2}} |u\_0^{n+\frac{1}{2}}| \\ &\approx -\sin\left(\frac{1}{2} (u\_1^{n+1} + u\_1 n)\right) - \beta \frac{1}{4} (u\_0^{n+1} + u\_0^n) |u\_0^{n+1} + u\_0^n|, \quad (5.16) \end{split}$$

$$\frac{u\_1^{n+1} - u\_1^n}{\Delta t} = u\_0^{n + \frac{1}{2}} \approx \frac{1}{2} (u\_0^{n+1} + u\_0^n) \,. \tag{5.17}$$

This is a *coupled system* of two nonlinear algebraic equations in two unknowns u<sup>n</sup>C<sup>1</sup> <sup>0</sup> and u<sup>n</sup>C<sup>1</sup> <sup>1</sup> .

Using the notation u0 and u1 for the unknowns u<sup>n</sup>C<sup>1</sup> <sup>0</sup> and u<sup>n</sup>C<sup>1</sup> <sup>1</sup> in this system, writing u.1/ <sup>0</sup> and <sup>u</sup>.1/ <sup>1</sup> for the previous values u<sup>n</sup> <sup>0</sup> and u<sup>n</sup> <sup>1</sup> , multiplying by t and moving the terms to the left-hand sides, gives

$$u\_0 - u\_0^{(1)} + \Delta t \sin\left(\frac{1}{2}(u\_1 + u\_1^{(1)})\right) + \frac{1}{4}\Delta t \beta (u\_0 + u\_0^{(1)})|u\_0 + u\_0^{(1)}| = 0,\tag{5.18}$$

$$u\_1 - u\_1^{(1)} - \frac{1}{2}\Delta t (u\_0 + u\_0^{(1)}) = 0.\tag{5.19}$$

2

Obviously, we have a need for solving systems of nonlinear algebraic equations, which is the topic of the next section.

#### **5.2 Systems of Nonlinear Algebraic Equations**

Implicit time discretization methods for a system of ODEs, or a PDE, lead to *systems* of nonlinear algebraic equations, written compactly as

$$F(u) = 0,$$

where <sup>u</sup> is a vector of unknowns <sup>u</sup> <sup>D</sup> .u0;:::;uN /, and <sup>F</sup> is a vector function: <sup>F</sup> <sup>D</sup> .F0;:::;FN /. The system at the end of Sect. 5.1.12 fits this notation with <sup>N</sup> D <sup>1</sup>, F0.u/ given by the left-hand side of (5.18), while F1.u/ is the left-hand side of (5.19).

Sometimes the equation system has a special structure because of the underlying problem, e.g.,

$$A(u)u = b(u),$$

with A.u/ as an .N C 1/-.N C 1/ matrix function of <sup>u</sup> and <sup>b</sup> as a vector function: <sup>b</sup> <sup>D</sup> .b0;:::;bN /.

We shall next explain how Picard iteration and Newton's method can be applied to systems like F .u/ D <sup>0</sup> and A.u/u D b.u/. The exposition has a focus on ideas and practical computations. More theoretical considerations, including quite general results on convergence properties of these methods, can be found in Kelley [8].

#### **5.2.1 Picard Iteration**

We cannot apply Picard iteration to nonlinear equations unless there is some special structure. For the commonly arising case A.u/u D b.u/ we can linearize the product A.u/u to A.u/u and b.u/ as b.u/. That is, we use the most previously computed approximation in A and b to arrive at a *linear system* for u:

$$A(u^{-})u = b(u^{-})\,.$$

A relaxed iteration takes the form

$$A(u^{-})u^{\*} = b(u^{-}), \quad u = \alpha u^{\*} + (1 - \alpha)u^{-}.$$

In other words, we solve a system of nonlinear algebraic equations as a sequence of linear systems.

#### **Algorithm for relaxed Picard iteration**

Given A.u/u D b.u/ and an initial guess <sup>u</sup>, iterate until convergence:

1. solve A.u/u D b.u/ with respect to <sup>u</sup> 2. <sup>u</sup> D !u C .1 !/u 3. <sup>u</sup> <sup>u</sup>

"Until convergence" means that the iteration is stopped when the change in the unknown, jj<sup>u</sup> <sup>u</sup>jj, or the residual jjA.u/u <sup>b</sup>jj, is sufficiently small, see Sect. 5.2.3 for more details.

#### **5.2.2 Newton's Method**

The natural starting point for Newton's method is the general nonlinear vector equation F .u/ D <sup>0</sup>. As for a scalar equation, the idea is to approximate <sup>F</sup> around a known value <sup>u</sup> by a linear function <sup>F</sup>O, calculated from the first two terms of a Taylor expansion of F . In the multi-variate case these two terms become

$$F(u^{-}) + J(u^{-}) \cdot (u - u^{-}),$$

where J is the *Jacobian* of F , defined by

$$J\_{i,j} = \frac{\partial F\_i}{\partial u\_j}.$$

So, the original nonlinear system is approximated by

$$
\hat{F}(u) = F(u^-) + J(u^-) \cdot (u - u^-) = 0,
$$

which is linear in <sup>u</sup> and can be solved in a two-step procedure: first solve J ıu D F .u/ with respect to the vector ıu and then update <sup>u</sup> D <sup>u</sup> C ıu. A relaxation parameter can easily be incorporated:

$$u = a(u^- + \delta u) + (1 - \omega)u^- = u^- + a\delta u \dots$$

#### **Algorithm for Newton's method**

Given F .u/ D <sup>0</sup> and an initial guess <sup>u</sup>, iterate until convergence:


For the special system with structure A.u/u D b.u/,

$$F\_i = \sum\_k A\_{i,k}(u)u\_k - b\_i(u),$$

one gets

$$J\_{i,j} = \sum\_{k} \frac{\partial A\_{i,k}}{\partial u\_j} u\_k + A\_{i,j} - \frac{\partial b\_i}{\partial u\_j}.\tag{5.20}$$

We realize that the Jacobian needed in Newton's method consists of A.u/ as in the Picard iteration plus two additional terms arising from the differentiation. Using the notation A<sup>0</sup> .u/ for @A=@u (a quantity with three indices: @Ai;k=@uj ), and b<sup>0</sup> .u/ for @b=@u (a quantity with two indices: @bi=@uj ), we can write the linear system to be solved as

$$(A + A'u + b')\delta u = -Au + b,$$

or

$$(A(u^{-}) + A'(u^{-})u^{-} + b'(u^{-})) \delta u = -A(u^{-})u^{-} + b(u^{-}).$$

Rearranging the terms demonstrates the difference from the system solved in each Picard iteration:

$$\underbrace{A(u^{-})(u^{-}+\delta u) - b(u^{-})}\_{\text{Picard system}} + \gamma (A'(u^{-})u^{-} + b'(u^{-})) \delta u = 0 \dots$$

Here we have inserted a parameter such that D <sup>0</sup> gives the Picard system and D <sup>1</sup> gives the Newton system. Such a parameter can be handy in software to easily switch between the methods.

**Combined algorithm for Picard and Newton iteration**

Given A.u/, b.u/, and an initial guess u, iterate until convergence:


#### **5.2.3 Stopping Criteria**

Let jj jj be the standard Euclidean vector norm. Four termination criteria are much in use:


To prevent divergent iterations to run forever, one terminates the iterations when the current number of iterations k exceeds a maximum value kmax.

The relative criteria are most used since they are not sensitive to the characteristic size of u. Nevertheless, the relative criteria can be misleading when the initial start value for the iteration is very close to the solution, since an unnecessary reduction in the error measure is enforced. In such cases the absolute criteria work better. It is common to combine the absolute and relative measures of the size of the residual, as in

$$||F(u)|| \le \epsilon\_{rr} ||F(u\_0)|| + \epsilon\_{ra},\tag{5.21}$$

where rr is the tolerance in the relative criterion and ra is the tolerance in the absolute criterion. With a very good initial guess for the iteration (typically the solution of a differential equation at the previous time level), the term jjF .u0/jj is small and ra is the dominating tolerance. Otherwise, rr jjF .u0/jj and the relative criterion dominates.

With the change in solution as criterion we can formulate a combined absolute and relative measure of the change in the solution:

$$||\delta u|| \le \epsilon\_{ur} ||u\_0|| + \epsilon\_{ua} \,. \tag{5.22}$$

The ultimate termination criterion, combining the residual and the change in solution with a test on the maximum number of iterations, can be expressed as

$$||F(u)|| \le \epsilon\_{rr} ||F(u\_0)|| + \epsilon\_{ra} \quad \text{or} \quad ||\delta u|| \le \epsilon\_{ur} ||u\_0|| + \epsilon\_{ua} \quad \text{or} \quad k > k\_{\text{max}} \tag{5.23}$$

#### **5.2.4 Example: A Nonlinear ODE Model from Epidemiology**

A very simple model for the spreading of a disease, such as a flu, takes the form of <sup>a</sup> <sup>2</sup> -2 ODE system

$$S' = -\beta SI,\tag{5.24}$$

$$I' = \beta SI - \nu I,\tag{5.25}$$

where S.t/ is the number of people who can get ill (susceptibles) and I.t/ is the number of people who are ill (infected). The constants ˇ>0 and >0 must be given along with initial conditions S.0/ and I.0/.

**Implicit time discretization** A Crank-Nicolson scheme leads to a <sup>2</sup> - 2 system of nonlinear algebraic equations in the unknowns S<sup>n</sup>C<sup>1</sup> and I <sup>n</sup>C<sup>1</sup>:

$$\frac{S^{n+1} - S^n}{\Delta t} = -\beta [SI]^{n+\frac{1}{2}} \approx -\frac{\beta}{2} (S^n I^n + S^{n+1} I^{n+1}),\tag{5.26}$$

$$\frac{I^{n+1} - I^n}{\Delta t} = \beta [SI]^{n + \frac{1}{2}} - \nu I^{n + \frac{1}{2}} \approx \frac{\beta}{2} (S^n I^n + S^{n+1} I^{n+1}) - \frac{\nu}{2} (I^n + I^{n+1}).\tag{5.27}$$

Introducing S for S<sup>n</sup>C<sup>1</sup>, S.1/ for S<sup>n</sup>, I for I <sup>n</sup>C<sup>1</sup> and I .1/ for I <sup>n</sup>, we can rewrite the system as

$$F\_S(S, I) = S - S^{(1)} + \frac{1}{2} \Delta t \beta(S^{(1)} I^{(1)} + SI) = 0,\tag{5.28}$$

$$F\_I(S, I) = I - I^{(1)} - \frac{1}{2} \Delta t \beta(S^{(1)} I^{(1)} + SI) + \frac{1}{2} \Delta t \nu(I^{(1)} + I) = 0. \quad (5.29)$$

**A Picard iteration** We assume that we have approximations S and I to S and I , respectively. A way of linearizing the only nonlinear term SI is to write I S in the FS <sup>D</sup> <sup>0</sup> equation and <sup>S</sup><sup>I</sup> in the FI <sup>D</sup> <sup>0</sup> equation, which also *decouples* the equations. Solving the resulting linear equations with respect to the unknowns S and I gives

$$\begin{aligned} S &= \frac{S^{(1)} - \frac{1}{2} \Delta t \beta S^{(1)} I^{(1)}}{1 + \frac{1}{2} \Delta t \beta I^{-}}, \\ I &= \frac{I^{(1)} + \frac{1}{2} \Delta t \beta S^{(1)} I^{(1)} - \frac{1}{2} \Delta t \nu I^{(1)}}{1 - \frac{1}{2} \Delta t \beta S^{-} + \frac{1}{2} \Delta t \nu}. \end{aligned}$$

Before a new iteration, we must update <sup>S</sup> <sup>S</sup> and <sup>I</sup> <sup>I</sup> .

**Newton's method** The nonlinear system (5.28)–(5.29) can be written as F .u/ D <sup>0</sup> with <sup>F</sup> <sup>D</sup> .FS ; FI / and <sup>u</sup> <sup>D</sup> .S; I /. The Jacobian becomes

$$J = \begin{pmatrix} \frac{\partial}{\partial S} F\_S & \frac{\partial}{\partial I} F\_S\\ \frac{\partial}{\partial S} F\_I & \frac{\partial}{\partial I} F\_I \end{pmatrix} = \begin{pmatrix} 1 + \frac{1}{2} \Delta t \beta I & \frac{1}{2} \Delta t \beta S\\ -\frac{1}{2} \Delta t \beta I & 1 - \frac{1}{2} \Delta t \beta S + \frac{1}{2} \Delta t \nu \end{pmatrix}.$$

The Newton system J.u/ıu D F .u/ to be solved in each iteration is then

$$
\begin{pmatrix}
1 + \frac{1}{2}\Delta t \beta I^- & \frac{1}{2}\Delta t \beta S^- \\
\end{pmatrix}
\begin{pmatrix}
\delta S \\
\delta I
\end{pmatrix}
$$

$$
\begin{aligned}
\label{eq:L}
& \begin{pmatrix}
S^- - S^{(1)} + \frac{1}{2}\Delta t \beta (S^{(1)} I^{(1)} + S^- I^-) \\
I^- - I^{(1)} - \frac{1}{2}\Delta t \beta (S^{(1)} I^{(1)} + S^- I^-) + \frac{1}{2}\Delta t \nu (I^{(1)} + I^-)
\end{pmatrix}
\end{aligned}
$$

**Remark** For this particular system of ODEs, explicit time integration methods work very well. Even a Forward Euler scheme is fine, but (as also experienced more generally) the 4-th order Runge-Kutta method is an excellent balance between high accuracy, high efficiency, and simplicity.

#### **5.3 Linearization at the Differential Equation Level**

The attention is now turned to nonlinear partial differential equations (PDEs) and application of the techniques explained above for ODEs. The model problem is a nonlinear diffusion equation for u.x;t/:

$$\frac{\partial u}{\partial t} = \nabla \cdot (\alpha(u)\nabla u) + f(u), \qquad \mathbf{x} \in \mathcal{Q}, \ t \in (0, T], \tag{5.30}$$

$$-a(u)\frac{\partial u}{\partial n} = \mathbf{g},\tag{5.31}$$

$$u = u\_0, \qquad \qquad \qquad \mathbf{x} \in \partial \mathcal{Q}\_D, \ t \in (0, T]. \qquad (5.32)$$

In the present section, our aim is to discretize this problem in time and then present techniques for linearizing the time-discrete PDE problem "at the PDE level" such that we transform the nonlinear stationary PDE problem at each time level into a sequence of linear PDE problems, which can be solved using any method for linear PDEs. This strategy avoids the solution of systems of nonlinear algebraic equations. In Sect. 5.4 we shall take the opposite (and more common) approach: discretize the nonlinear problem in time and space first, and then solve the resulting nonlinear algebraic equations at each time level by the methods of Sect. 5.2. Very often, the two approaches are mathematically identical, so there is no preference from a computational efficiency point of view. The details of the ideas sketched above will hopefully become clear through the forthcoming examples.

#### **5.3.1 Explicit Time Integration**

The nonlinearities in the PDE are trivial to deal with if we choose an explicit time integration method for (5.30), such as the Forward Euler method:

$$[D\_t^+u = \nabla \cdot (\alpha(u)\nabla u) + f(u)]^n,$$

or written out,

$$\frac{u^{n+1} - u^n}{\Delta t} = \nabla \cdot (\alpha(u^n) \nabla u^n) + f(u^n),$$

which is a linear equation in the unknown u<sup>n</sup>C<sup>1</sup> with solution

$$u^{n+1} = u^n + \Delta t \nabla \cdot (\alpha(u^n) \nabla u^n) + \Delta t f(u^n) \,.$$

The disadvantage with this discretization is the strict stability criterion t h2=.6 max ˛/ for the case <sup>f</sup> D <sup>0</sup> and a standard 2nd-order finite difference discretization in 3D space with mesh cell sizes <sup>h</sup> D x D y D z.

#### **5.3.2 Backward Euler Scheme and Picard Iteration**

A Backward Euler scheme for (5.30) reads

$$[D\_\iota^-u = \nabla \cdot (\alpha(u)\nabla u) + f(u)]^n \,.$$

Written out,

$$\frac{u^n - u^{n-1}}{\Delta t} = \nabla \cdot \left( \alpha(u^n) \nabla u^n \right) + f(u^n) \,. \tag{5.33}$$

This is a nonlinear PDE for the unknown function <sup>u</sup>n.x/. Such a PDE can be viewed as a time-independent PDE where <sup>u</sup><sup>n</sup>1.x/ is a known function.

We introduce a Picard iteration with k as iteration counter. A typical linearization of the r .˛.un/run/ term in iteration <sup>k</sup> C <sup>1</sup> is to use the previously computed <sup>u</sup>n;k approximation in the diffusion coefficient: ˛.un;k/. The nonlinear source term is treated similarly: f .un;k /. The unknown function un;kC<sup>1</sup> then fulfills the linear PDE

$$\frac{u^{n,k+1} - u^{n-1}}{\Delta t} = \nabla \cdot \left( \alpha(u^{n,k}) \nabla u^{n,k+1} \right) + f(u^{n,k}) \,. \tag{5.34}$$

The initial guess for the Picard iteration at this time level can be taken as the solution at the previous time level: <sup>u</sup>n;0 <sup>D</sup> <sup>u</sup><sup>n</sup><sup>1</sup>.

We can alternatively apply the implementation-friendly notation where u corresponds to the unknown we want to solve for, i.e., un;kC<sup>1</sup> above, and u is the most recently computed value, un;k above. Moreover, u.1/ denotes the unknown function at the previous time level, u<sup>n</sup><sup>1</sup> above. The PDE to be solved in a Picard iteration then looks like

$$\frac{\mu - u^{(1)}}{\Delta t} = \nabla \cdot \left( \alpha(u^{-}) \nabla u \right) + f(u^{-}) \,. \tag{5.35}$$

At the beginning of the iteration we start with the value from the previous time level: <sup>u</sup> <sup>D</sup> <sup>u</sup>.1/, and after each iteration, <sup>u</sup> is updated to <sup>u</sup>.

#### **Remark on notation**

The previous derivations of the numerical scheme for time discretizations of PDEs have, strictly speaking, a somewhat sloppy notation, but it is much used and convenient to read. A more precise notation must distinguish clearly between the exact solution of the PDE problem, here denoted <sup>u</sup>e.x;t/, and the exact solution of the spatial problem, arising after time discretization at each time level, where (5.33) is an example. The latter is here represented as <sup>u</sup>n.x/ and is an approximation to <sup>u</sup>e.x; tn/. Then we have another approximation <sup>u</sup>n;k.x/ to <sup>u</sup>n.x/ when solving the nonlinear PDE problem for <sup>u</sup><sup>n</sup> by iteration methods, as in (5.34).

In our notation, u is a synonym for un;kC<sup>1</sup> and u.1/ is a synonym for u<sup>n</sup><sup>1</sup>, inspired by what are natural variable names in a code. We will usually state the PDE problem in terms of u and quickly redefine the symbol u to mean the numerical approximation, while ue is not explicitly introduced unless we need to talk about the exact solution and the approximate solution at the same time.

#### **5.3.3 Backward Euler Scheme and Newton's Method**

At time level n, we have to solve the stationary PDE (5.33). In the previous section, we saw how this can be done with Picard iterations. Another alternative is to apply the idea of Newton's method in a clever way. Normally, Newton's method is defined for systems of *algebraic equations*, but the idea of the method can be applied at the PDE level too.

**Linearization via Taylor expansions** Let un;k be an approximation to the unknown u<sup>n</sup>. We seek a better approximation on the form

$$u^n = u^{n,k} + \delta u \,. \tag{5.36}$$

The idea is to insert (5.36) in (5.33), Taylor expand the nonlinearities and keep only the terms that are linear in ıu (which makes (5.36) an approximation for u<sup>n</sup>). Then we can solve a linear PDE for the correction ıu and use (5.36) to find a new approximation

$$u^{n,k+1} = u^{n,k} + \delta u$$

to <sup>u</sup><sup>n</sup>. Repeating this procedure gives a sequence <sup>u</sup>n;kC<sup>1</sup>, <sup>k</sup> <sup>D</sup> 0; 1; : : : that hopefully converges to the goal u<sup>n</sup>.

Let us carry out all the mathematical details for the nonlinear diffusion PDE discretized by the Backward Euler method. Inserting (5.36) in (5.33) gives

$$\frac{u^{n,k} + \delta u - u^{n-1}}{\Delta t} = \nabla \cdot \left( a(u^{n,k} + \delta u) \nabla (u^{n,k} + \delta u) \right) + f(u^{n,k} + \delta u) \,. \tag{5.37}$$

We can Taylor expand ˛.un;k C ıu/ and f .un;k C ıu/:

$$\begin{aligned} \alpha(u^{n,k} + \delta u) &= \alpha(u^{n,k}) + \frac{d\alpha}{du}(u^{n,k})\delta u + \mathcal{O}(\delta u^2) \approx \alpha(u^{n,k}) + \alpha'(u^{n,k})\delta u, \\\ f(u^{n,k} + \delta u) &= f(u^{n,k}) + \frac{df}{du}(u^{n,k})\delta u + \mathcal{O}(\delta u^2) \approx f(u^{n,k}) + f'(u^{n,k})\delta u \,. \end{aligned}$$

Inserting the linear approximations of ˛ and f in (5.37) results in

$$\begin{split} \frac{u^{n,k} + \delta u - u^{n-1}}{\Delta t} &= \nabla \cdot (a(u^{n,k}) \nabla u^{n,k}) + f(u^{n,k}) \\ &+ \nabla \cdot (a(u^{n,k}) \nabla \delta u) + \nabla \cdot (a'(u^{n,k}) \delta u \nabla u^{n,k}) \\ &+ \nabla \cdot (a'(u^{n,k}) \delta u \nabla \delta u) + f'(u^{n,k}) \delta u \,. \end{split} \tag{5.38}$$

The term ˛<sup>0</sup> .un;k/ıurıu is of order ıu<sup>2</sup> and therefore omitted since we expect the correction ıu to be small (ıu ıu<sup>2</sup>). Reorganizing the equation gives a PDE for ıu that we can write in short form as

$$
\delta F(\delta u; u^{n,k}) = -F(u^{n,k}),
$$

where

$$F(u^{n,k}) = \frac{u^{n,k} - u^{n-1}}{\Delta t} - \nabla \cdot (\alpha(u^{n,k}) \nabla u^{n,k}) + f(u^{n,k}), \tag{5.39}$$

$$\begin{split} \delta F(\delta u; u^{n,k}) &= -\frac{1}{\Delta t} \delta u + \nabla \cdot (a(u^{n,k}) \nabla \delta u) \\ &+ \nabla \cdot (a'(u^{n,k}) \delta u \nabla u^{n,k}) + f'(u^{n,k}) \delta u \,. \end{split} \tag{5.40}$$

Note that ıF is a linear function of ıu, and F contains only terms that are known, such that the PDE for ıu is indeed linear.

#### **Observations**

The notational form ıF D <sup>F</sup> resembles the Newton system J ıu D <sup>F</sup> for systems of algebraic equations, with ıF as J ıu. The unknown vector in a linear system of algebraic equations enters the system as a linear operator in terms of a matrix-vector product (J ıu), while at the PDE level we have a linear differential operator instead (ıF).

**Similarity with Picard iteration** We can rewrite the PDE for ıu in a slightly different way too if we define <sup>u</sup>n;k <sup>C</sup> ıu as <sup>u</sup>n;kC<sup>1</sup>.

$$\begin{split} \frac{u^{n,k+1} - u^{n-1}}{\Delta t} &= \nabla \cdot (\alpha(u^{n,k}) \nabla u^{n,k+1}) + f(u^{n,k}) \\ &+ \nabla \cdot (\alpha'(u^{n,k}) \delta u \nabla u^{n,k}) + f'(u^{n,k}) \delta u \,. \end{split} \tag{5.41}$$

Note that the first line is the same PDE as arises in the Picard iteration, while the remaining terms arise from the differentiations that are an inherent ingredient in Newton's method.

**Implementation** For coding we want to introduce u for u<sup>n</sup>, u for un;k and u.1/ for u<sup>n</sup><sup>1</sup>. The formulas for F and ıF are then more clearly written as

$$F(u^{-}) = \frac{u^{-} - u^{(1)}}{\Delta t} - \nabla \cdot (\alpha(u^{-}) \nabla u^{-}) + f(u^{-}),\tag{5.42}$$

$$\begin{split} \delta F(\delta u; u^{-}) &= -\frac{1}{\Delta t} \delta u + \nabla \cdot (a(u^{-}) \nabla \delta u) \\ &+ \nabla \cdot (a'(u^{-}) \delta u \nabla u^{-}) + f'(u^{-}) \delta u \,. \end{split} \tag{5.43}$$

The form that orders the PDE as the Picard iteration terms plus the Newton method's derivative terms becomes

$$\begin{split} \frac{\boldsymbol{u} - \boldsymbol{u}^{(l)}}{\Delta t} &= \nabla \cdot \left( a(\boldsymbol{u}^{-}) \nabla \boldsymbol{u} \right) + f(\boldsymbol{u}^{-}) \\ &+ \gamma \left( \nabla \cdot \left( \boldsymbol{\alpha}^{\prime}(\boldsymbol{u}^{-}) (\boldsymbol{u} - \boldsymbol{u}^{-}) \nabla \boldsymbol{u}^{-} \right) + f^{\prime}(\boldsymbol{u}^{-}) (\boldsymbol{u} - \boldsymbol{u}^{-}) \right) . \end{split} \tag{5.44}$$

The Picard and full Newton versions correspond to D <sup>0</sup> and D <sup>1</sup>, respectively. **Derivation with alternative notation** Some may prefer to derive the linearized PDE for ıu using the more compact notation. We start with inserting <sup>u</sup><sup>n</sup> D <sup>u</sup> Cıu to get

$$\frac{u^{-} + \delta u - u^{n-1}}{\Delta t} = \nabla \cdot \left( a(u^{-} + \delta u) \nabla (u^{-} + \delta u) \right) + f(u^{-} + \delta u) \cdot \Delta t$$

Taylor expanding,

$$\begin{aligned} \alpha(\mathfrak{u}^- + \delta \mathfrak{u}) &\approx \alpha(\mathfrak{u}^-) + \alpha'(\mathfrak{u}^-) \delta \mathfrak{u}, \\ f(\mathfrak{u}^- + \delta \mathfrak{u}) &\approx f(\mathfrak{u}^-) + f'(\mathfrak{u}^-) \delta \mathfrak{u}, \\ \mathfrak{x} &\quad \mathfrak{x} \end{aligned}$$

and inserting these expressions gives a less cluttered PDE for ıu:

$$\begin{split} \frac{u^{-} + \delta u - u^{n-1}}{\Delta t} &= \nabla \cdot (\boldsymbol{\alpha}(\boldsymbol{u}^{-}) \nabla \boldsymbol{u}^{-}) + f(\boldsymbol{u}^{-}) \\ &+ \nabla \cdot (\boldsymbol{\alpha}(\boldsymbol{u}^{-}) \nabla \delta \boldsymbol{u}) + \nabla \cdot (\boldsymbol{\alpha}'(\boldsymbol{u}^{-}) \delta \boldsymbol{u} \nabla \boldsymbol{u}^{-}) \\ &+ \nabla \cdot (\boldsymbol{\alpha}'(\boldsymbol{u}^{-}) \delta \boldsymbol{u} \nabla \delta \boldsymbol{u}) + f'(\boldsymbol{u}^{-}) \delta \boldsymbol{u} \,. \end{split}$$

#### **5.3.4 Crank-Nicolson Discretization**

A Crank-Nicolson discretization of (5.30) applies a centered difference at t nC 1 2 :

$$[D\_l u = \nabla \cdot (\alpha(\mu) \nabla u) + f(\mu)]^{n + \frac{1}{2}} \dots$$

The standard technique is to apply an arithmetic average for quantities defined between two mesh points, e.g.,

$$u^{n+\frac{1}{2}} \approx \frac{1}{2} (u^n + u^{n+1}) \,.$$

However, with nonlinear terms we have many choices of formulating an arithmetic mean:

$$[f(u)]^{n+\frac{1}{2}} \approx f\left(\frac{1}{2}(u^n + u^{n+1})\right) = [f(\overline{u}')]^{n+\frac{1}{2}},\tag{5.45}$$

$$\mathbb{E}\left[f(u)\right]^{n+\frac{1}{2}} \approx \frac{1}{2} (f(u^n) + f(u^{n+1})) = \left[\overline{f(u)}'\right]^{n+\frac{1}{2}},\tag{5.46}$$

$$[\alpha(u)\nabla u]^{n+\frac{1}{2}} \approx \alpha\left(\frac{1}{2}(u^n + u^{n+1})\right)\nabla\left(\frac{1}{2}(u^n + u^{n+1})\right) = \left[\alpha(\overline{u}')\nabla\overline{u}'\right]^{n+\frac{1}{2}},\tag{5.47}$$

$$[\alpha(u)\nabla u]^{n+\frac{1}{2}} \approx \frac{1}{2}(\alpha(u^n) + \alpha(u^{n+1}))\nabla \left(\frac{1}{2}(u^n + u^{n+1})\right) = \left[\overline{\alpha(u)}^l \nabla \overline{u}^l\right]^{n+\frac{1}{2}},\tag{5.48}$$

$$[\alpha(u)\nabla u]^{n+\frac{1}{2}} \approx \frac{1}{2}(\alpha(u^n)\nabla u^n + \alpha(u^{n+1})\nabla u^{n+1}) = \left[\overline{\alpha(u)\nabla u}^{n+\frac{1}{2}}\right]^{n+\frac{1}{2}}.\qquad(5.49)$$

A big question is whether there are significant differences in accuracy between taking the products of arithmetic means or taking the arithmetic mean of products. Exercise 5.6 investigates this question, and the answer is that the approximation is *<sup>O</sup>*.t 2/ in both cases.

#### **5.4 1D Stationary Nonlinear Differential Equations**

Section 5.3 presented methods for linearizing time-discrete PDEs directly prior to discretization in space. We can alternatively carry out the discretization in space of the time-discrete nonlinear PDE problem and get a system of nonlinear algebraic equations, which can be solved by Picard iteration or Newton's method as presented in Sect. 5.2. This latter approach will now be described in detail.

We shall work with the 1D problem

$$- (a(u)u')' + au = f(u), \quad x \in (0, L), \quad a(u(0))u'(0) = C, \ u(L) = D. \tag{5.50}$$

The problem (5.50) arises from the stationary limit of a diffusion equation,

$$\frac{\partial u}{\partial t} = \frac{\partial}{\partial \mathbf{x}} \left( a(u) \frac{\partial u}{\partial \mathbf{x}} \right) - au + f(u), \tag{5.51}$$

as <sup>t</sup> ! 1 and @u=@t ! <sup>0</sup>. Alternatively, the problem (5.50) arises at each time level from implicit time discretization of (5.51). For example, a Backward Euler scheme for (5.51) leads to

$$\frac{u^n - u^{n-1}}{\Delta t} = \frac{d}{d\boldsymbol{x}}\left(\alpha(u^n)\frac{d u^n}{d\boldsymbol{x}}\right) - au^n + f(u^n) \,. \tag{5.52}$$

Introducing u.x/ for un.x/, u.1/ for u<sup>n</sup><sup>1</sup>, and defining f .u/ in (5.50) to be f .u/ in (5.52) plus <sup>u</sup><sup>n</sup>1=t, gives (5.50) with <sup>a</sup> D 1=t.

#### **5.4.1 Finite Difference Discretization**

The nonlinearity in the differential equation (5.50) poses no more difficulty than a variable coefficient, as in the term .˛.x/u<sup>0</sup> /0 . We can therefore use a standard finite difference approach when discretizing the Laplace term with a variable coefficient:

$$[-D\_x \alpha D\_x u + au = f]\_{l\_-} $$

Writing this out for a uniform mesh with points xi <sup>D</sup> ix, <sup>i</sup> <sup>D</sup> 0; : : : ; Nx, leads to

$$-\frac{1}{\Delta x^2} \left( \alpha\_{i + \frac{1}{2}} (u\_{i+1} - u\_i) - \alpha\_{i - \frac{1}{2}} (u\_i - u\_{i-1}) \right) + a u\_i = f(u\_i) \,. \tag{5.53}$$

This equation is valid at all the mesh points <sup>i</sup> <sup>D</sup> 0; 1; : : : ; Nx <sup>1</sup>. At <sup>i</sup> <sup>D</sup> Nx we have the Dirichlet condition ui <sup>D</sup> <sup>0</sup>. The only difference from the case with .˛.x/u0 /<sup>0</sup> and f .x/ is that now ˛ and f are functions of u and not only of x: .˛.u.x//u0 /<sup>0</sup> and f .u.x//.

The quantity ˛iC <sup>1</sup> 2 , evaluated between two mesh points, needs a comment. Since ˛ depends on <sup>u</sup> and <sup>u</sup> is only known at the mesh points, we need to express ˛iC <sup>1</sup> 2 in terms of ui and uiC<sup>1</sup>. For this purpose we use an arithmetic mean, although a harmonic mean is also common in this context if ˛ features large jumps. There are two choices of arithmetic means:

$$
\alpha\_{i+\frac{1}{2}} \approx \alpha \left(\frac{1}{2} (u\_i + u\_{i+1})\right) = [\alpha(\overline{u}^\chi)]^{i+\frac{1}{2}},\tag{5.54}
$$

$$\alpha\_{i+\frac{1}{2}} \approx \frac{1}{2} (\alpha(u\_i) + \alpha(u\_{i+1})) = \left[\overline{\alpha(u)}^{\chi}\right]^{i+\frac{1}{2}}.\tag{5.55}$$

Equation (5.53) with the latter approximation then looks like

$$\begin{aligned} & -\frac{1}{2\Delta x^2} \left( (a(u\_i) + a(u\_{i+1}))(u\_{i+1} - u\_i) - (a(u\_{i-1}) + a(u\_i))(u\_i - u\_{i-1}) \right) \\ & + au\_i = f(u\_i), \end{aligned} \tag{5.56}$$

or written more compactly,

$$[-D\_{\underline{x}}\overline{\alpha}^{\underline{x}}D\_{\underline{x}}u + au = f]\_{\underline{i}}\\_{\underline{j}}$$

At mesh point <sup>i</sup> D <sup>0</sup> we have the boundary condition ˛.u/u<sup>0</sup> D <sup>C</sup>, which is discretized by

$$[\alpha(u)D\_{2x}u = C]\_{0,1}$$

meaning

$$
\alpha(u\_0) \frac{u\_1 - u\_{-1}}{2\Delta x} = C\,. \tag{5.57}
$$

The fictitious value <sup>u</sup><sup>1</sup> can be eliminated with the aid of (5.56) for <sup>i</sup> <sup>D</sup> <sup>0</sup>. Formally, (5.56) should be solved with respect to ui<sup>1</sup> and that value (for <sup>i</sup> <sup>D</sup> <sup>0</sup>) should be inserted in (5.57), but it is algebraically much easier to do it the other way around. Alternatively, one can use a ghost cell <sup>Œ</sup>x; 0 and update the <sup>u</sup><sup>1</sup> value in the ghost cell according to (5.57) after every Picard or Newton iteration. Such an approach means that we use a known <sup>u</sup><sup>1</sup> value in (5.56) from the previous iteration.

#### **5.4.2 Solution of Algebraic Equations**

**The structure of the equation system** The nonlinear algebraic equations (5.56) are of the form A.u/u D b.u/ with

$$\begin{aligned} A\_{i,i} &= \frac{1}{2\Delta x^2} (\alpha(u\_{i-1}) + 2\alpha(u\_i)\alpha(u\_{i+1})) + a, \\ A\_{i,i-1} &= -\frac{1}{2\Delta x^2} (\alpha(u\_{i-1}) + \alpha(u\_i)), \\ A\_{i,i+1} &= -\frac{1}{2\Delta x^2} (\alpha(u\_i) + \alpha(u\_{i+1})), \\ b\_i &= f(u\_i) \ . \end{aligned}$$

The matrix A.u/ is tridiagonal: Ai;j <sup>D</sup> <sup>0</sup> for j >i <sup>C</sup> <sup>1</sup> and j <i <sup>1</sup>.

The above expressions are valid for internal mesh points <sup>1</sup> <sup>i</sup> Nx <sup>1</sup>. For <sup>i</sup> <sup>D</sup> <sup>0</sup> we need to express ui<sup>1</sup> <sup>D</sup> <sup>u</sup><sup>1</sup> in terms of u1 using (5.57):

$$u\_{-1} = u\_1 - \frac{2\Delta x}{\alpha(u\_0)}C\,. \tag{5.58}$$

This value must be inserted in A0;0. The expression for Ai;iC<sup>1</sup> applies for <sup>i</sup> <sup>D</sup> <sup>0</sup>, and Ai;i<sup>1</sup> does not enter the system when <sup>i</sup> <sup>D</sup> <sup>0</sup>.

Regarding the last equation, its form depends on whether we include the Dirichlet condition u.L/ <sup>D</sup> <sup>D</sup>, meaning uNx <sup>D</sup> <sup>D</sup>, in the nonlinear algebraic equation system or not. Suppose we choose .u0; u1;:::;uNx1/ as unknowns, later referred to as *systems without Dirichlet conditions*. The last equation corresponds to <sup>i</sup> <sup>D</sup> Nx 1. It involves the boundary value uNx , which is substituted by <sup>D</sup>. If the unknown vector includes the boundary value, .u0; u1;:::;uNx /, later referred to as *system including Dirichlet conditions*, the equation for <sup>i</sup> <sup>D</sup> Nx <sup>1</sup> just involves the unknown uNx , and the final equation becomes uNx <sup>D</sup> <sup>D</sup>, corresponding to Ai;i <sup>D</sup> <sup>1</sup> and bi <sup>D</sup> <sup>D</sup> for <sup>i</sup> <sup>D</sup> Nx.

**Picard iteration** The obvious Picard iteration scheme is to use previously computed values of ui in A.u/ and b.u/, as described more in detail in Sect. 5.2. With the notation u for the most recently computed value of u, we have the system F .u/ F .u/ <sup>O</sup> D A.u/u b.u/, with <sup>F</sup> D .F0; F1;:::;Fm/, <sup>u</sup> D .u0; u1;:::;um/. The index m is Nx if the system includes the Dirichlet condition as a separate equation and Nx <sup>1</sup> otherwise. The matrix A.u/ is tridiagonal, so the solution procedure is to fill a tridiagonal matrix data structure and the righthand side vector with the right numbers and call a Gaussian elimination routine for tridiagonal linear systems.

**Mesh with two cells** It helps on the understanding of the details to write out all the mathematics in a specific case with a small mesh, say just two cells (Nx <sup>D</sup> <sup>2</sup>). We use u <sup>i</sup> for the i-th component in u.

The starting point is the basic expressions for the nonlinear equations at mesh point <sup>i</sup> D <sup>0</sup> and <sup>i</sup> D <sup>1</sup>:

$$A\_{0,-1}u\_{-1} + A\_{0,0}u\_0 + A\_{0,1}u\_1 = b\_0,\tag{5.59}$$

$$A\_{1,0}u\_0 + A\_{1,1}u\_1 + A\_{1,2}u\_2 = b\_1 \, . \tag{5.60}$$

Equation (5.59) written out reads

$$\begin{aligned} \frac{1}{2\Delta x^2} \Big( - \left( \alpha(u\_{-1}) + \alpha(u\_0) \right) u\_{-1} \\\\ + \left( \alpha(u\_{-1}) + 2\alpha(u\_0) + \alpha(u\_1) \right) u\_0 \\\\ - \left( \alpha(u\_0) + \alpha(u\_1) \right) \Big) u\_1 + a u\_0 &= f(u\_0) \,. \end{aligned}$$

We must then replace <sup>u</sup><sup>1</sup> by (5.58). With Picard iteration we get

$$\begin{aligned} \frac{1}{2\Delta x^2} \Big( - \left( \alpha(u\_{-1}^-) + 2\alpha(u\_0^-) + \alpha(u\_1^-) \right) u\_1 \\ + (\alpha(u\_{-1}^-) + 2\alpha(u\_0^-) + \alpha(u\_1^-)) \Big) u\_0 + a u\_0 \\ = f(u\_0^-) - \frac{1}{\alpha(u\_0^-) \Delta x} (\alpha(u\_{-1}^-) + \alpha(u\_0^-)) C, \end{aligned}$$

where

$$u\_{-1}^- = u\_1^- - \frac{2\Delta x}{\alpha(u\_0^-)}C\ .$$

Equation (5.60) contains the unknown u2 for which we have a Dirichlet condition. In case we omit the condition as a separate equation, (5.60) with Picard iteration becomes

$$\begin{aligned} \frac{1}{2\Delta x^2} \Big( - (\alpha(u\_0^-) + \alpha(u\_1^-))u\_0 \\ + (\alpha(u\_0^-) + 2\alpha(u\_1^-) + \alpha(u\_2^-))u\_1 \\ - (\alpha(u\_1^-) + \alpha(u\_2^-)) \Big) u\_2 + au\_1 &= f(u\_1^-) \dots \end{aligned}$$

We must now move the u2 term to the right-hand side and replace all occurrences of u2 by D:

$$\begin{aligned} \frac{1}{2\Delta x^2} \Big( - \left( \alpha(u\_0^-) + \alpha(u\_1^-) \right) u\_0 \\ + \left( \alpha(u\_0^-) + 2\alpha(u\_1^-) + \alpha(D) \right) \Big) u\_1 + a u\_1 \\ = f(u\_1^-) + \frac{1}{2\Delta x^2} (\alpha(u\_1^-) + \alpha(D)) D \,. \end{aligned}$$

The two equations can be written as a <sup>2</sup> -2 system:

$$
\left(\begin{array}{cc} B\_{0,0} & B\_{0,1} \\ B\_{1,0} & B\_{1,1} \end{array}\right) \left(\begin{array}{c} u\_0 \\ u\_1 \end{array}\right) = \left(\begin{array}{c} d\_0 \\ d\_1 \end{array}\right),
$$

where

$$B\_{0,0} = \frac{1}{2\Delta x^2} (\alpha(u\_{-1}^-) + 2\alpha(u\_0^-) + \alpha(u\_1^-)) + a,\tag{5.61}$$

$$B\_{0,1} = -\frac{1}{2\Delta x^2} (\alpha(u\_{-1}^-) + 2\alpha(u\_0^-) + \alpha(u\_1^-)),\tag{5.62}$$

$$B\_{1,0} = -\frac{1}{2\Delta x^2} (\alpha(u\_0^-) + \alpha(u\_1^-)),\tag{5.63}$$

$$B\_{1,1} = \frac{1}{2\Delta x^2} (\alpha(u\_0^-) + 2\alpha(u\_1^-) + \alpha(D)) + a,\tag{5.64}$$

$$d\_0 = f(u\_0^-) - \frac{1}{\alpha(u\_0^-)\Delta x} (\alpha(u\_{-1}^-) + \alpha(u\_0^-))C,\tag{5.65}$$

$$d\_1 = f(u\_1^-) + \frac{1}{2\Delta x^2} (\alpha(u\_1^-) + \alpha(D))D \,. \tag{5.66}$$

The system with the Dirichlet condition becomes

$$
\begin{pmatrix} B\_{0,0} & B\_{0,1} & 0 \\ \begin{array}{cc} B\_{1,0} & B\_{1,1} & B\_{1,2} \\ 0 & 0 & 1 \end{array} \end{pmatrix} \begin{pmatrix} u\_0 \\ u\_1 \\ u\_2 \end{pmatrix} = \begin{pmatrix} d\_0 \\ d\_1 \\ D \end{pmatrix},
$$

with

$$B\_{1,1} = \frac{1}{2\Delta x\_{\phantom{a}}^{2}}(\alpha(u\_0^{-}) + 2\alpha(u\_1^{-}) + \alpha(u\_2)) + a,\tag{5.67}$$

$$B\_{1,2} = -\frac{1}{2\Delta x^2} (\alpha(u\_1^-) + \alpha(u\_2))),\tag{5.68}$$

$$d\_1 = f(u\_1^-)\,. \tag{5.69}$$

Other entries are as in the <sup>2</sup> -2 system.

**Newton's method** The Jacobian must be derived in order to use Newton's method. Here it means that we need to differentiate F .u/ D A.u/u b.u/ with respect to the unknown parameters u0; u1;:::;um (<sup>m</sup> <sup>D</sup> Nx or <sup>m</sup> <sup>D</sup> Nx <sup>1</sup>, depending on whether the Dirichlet condition is included in the nonlinear system F .u/ D <sup>0</sup> or not). Nonlinear equation number i has the structure

$$F\_i = A\_{i,i-1}(u\_{i-1}, u\_i)u\_{i-1} + A\_{i,i}(u\_{i-1}, u\_i, u\_{i+1})u\_i + A\_{i,i+1}(u\_i, u\_{i+1})u\_{i+1} - b\_i(u\_i)\ .$$

Computing the Jacobian requires careful differentiation. For example,

$$\begin{split} \frac{\partial}{\partial u\_{i}}(A\_{i,i}(u\_{i-1},u\_{i},u\_{i+1})u\_{i}) &= \frac{\partial A\_{i,i}}{\partial u\_{i}}u\_{i} + A\_{i,i}\frac{\partial u\_{i}}{\partial u\_{i}} \\ &= \frac{\partial}{\partial u\_{i}}\left(\frac{1}{2\Delta x^{2}}(\alpha(u\_{i-1}) + 2\alpha(u\_{i}) + \alpha(u\_{i+1})) + a\right)u\_{i} \\ &+ \frac{1}{2\Delta x^{2}}(\alpha(u\_{i-1}) + 2\alpha(u\_{i}) + \alpha(u\_{i+1})) + a \\ &= \frac{1}{2\Delta x^{2}}(2\alpha'(u\_{i})u\_{i} + \alpha(u\_{i-1}) + 2\alpha(u\_{i}) + \alpha(u\_{i+1})) \\ &+ a \ . \end{split}$$

The complete Jacobian becomes

Ji;i <sup>D</sup> @Fi @ui D @Ai;i<sup>1</sup> @ui ui<sup>1</sup> <sup>C</sup> @Ai;i @ui ui <sup>C</sup> Ai;i <sup>C</sup> @Ai;iC<sup>1</sup> @ui uiC<sup>1</sup> @bi @ui D <sup>1</sup> 2x2 .˛<sup>0</sup> .ui/ui<sup>1</sup> <sup>C</sup> 2˛<sup>0</sup> .ui/ui <sup>C</sup> ˛.ui1/ <sup>C</sup> 2˛.ui/ <sup>C</sup> ˛.uiC1// C <sup>a</sup> <sup>1</sup> 2x2 ˛<sup>0</sup> .ui/uiC<sup>1</sup> <sup>b</sup><sup>0</sup> .ui/; Ji;i<sup>1</sup> <sup>D</sup> @Fi @ui<sup>1</sup> D @Ai;i<sup>1</sup> @ui<sup>1</sup> ui<sup>1</sup> <sup>C</sup> Ai1;i <sup>C</sup> @Ai;i @ui<sup>1</sup> ui @bi @ui<sup>1</sup> D <sup>1</sup> 2x2 .˛<sup>0</sup> .ui1/ui<sup>1</sup> .˛.ui1/ <sup>C</sup> ˛.ui// <sup>C</sup> ˛<sup>0</sup> .ui1/ui/; Ji;iC<sup>1</sup> <sup>D</sup> @Ai;iC<sup>1</sup> @ui<sup>1</sup> uiC<sup>1</sup> <sup>C</sup> AiC1;i <sup>C</sup> @Ai;i @uiC<sup>1</sup> ui @bi @uiC<sup>1</sup> D <sup>1</sup> 2x2 .˛<sup>0</sup> .uiC1/uiC<sup>1</sup> .˛.ui/ <sup>C</sup> ˛.uiC1// <sup>C</sup> ˛<sup>0</sup> .uiC1/ui/ :

The explicit expression for nonlinear equation number i, Fi.u0; u1; : : :/, arises from moving the f .ui/ term in (5.56) to the left-hand side:

$$\begin{split} F\_i &= -\frac{1}{2\Delta x^2} \left( (a(u\_i) + \alpha(u\_{i+1}))(u\_{i+1} - u\_i) - (\alpha(u\_{i-1}) + \alpha(u\_i))(u\_i - u\_{i-1}) \right) \\ &+ a u\_i - f(u\_i) = 0 \end{split} \tag{5.70}$$

At the boundary point <sup>i</sup> <sup>D</sup> <sup>0</sup>, <sup>u</sup><sup>1</sup> must be replaced using the formula (5.58). When the Dirichlet condition at <sup>i</sup> <sup>D</sup> Nx is not a part of the equation system, the last equation Fm <sup>D</sup> <sup>0</sup> for <sup>m</sup> <sup>D</sup> Nx <sup>1</sup> involves the quantity uNx<sup>1</sup> which must be replaced by D. If uNx is treated as an unknown in the system, the last equation Fm <sup>D</sup> <sup>0</sup> has <sup>m</sup> <sup>D</sup> Nx and reads

$$F\_{N\_{\mathcal{X}}}(\mathfrak{u}\_0, \dots, \mathfrak{u}\_{N\_{\mathcal{X}}}) = \mathfrak{u}\_{N\_{\mathcal{X}}} - D = 0 \dots$$

Similar replacement of <sup>u</sup><sup>1</sup> and uNx must be done in the Jacobian for the first and last row. When uNx is included as an unknown, the last row in the Jacobian must help implement the condition ıuNx <sup>D</sup> <sup>0</sup>, since we assume that <sup>u</sup> contains the right Dirichlet value at the beginning of the iteration (uNx <sup>D</sup> <sup>D</sup>), and then the Newton update should be zero for <sup>i</sup> <sup>D</sup> <sup>0</sup>, i.e., ıuNx <sup>D</sup> <sup>0</sup>. This also forces the right-hand side to be bi <sup>D</sup> <sup>0</sup>, <sup>i</sup> <sup>D</sup> Nx.

We have seen, and can see from the present example, that the linear system in Newton's method contains all the terms present in the system that arises in the Picard iteration method. The extra terms in Newton's method can be multiplied by a factor such that it is easy to program one linear system and set this factor to 0 or 1 to generate the Picard or Newton system.

#### **5.5 Multi-Dimensional Nonlinear PDE Problems**

The fundamental ideas in the derivation of Fi and Ji;j in the 1D model problem are easily generalized to multi-dimensional problems. Nevertheless, the expressions involved are slightly different, with derivatives in <sup>x</sup> replaced by r, so we present some examples below in detail.

#### **5.5.1 Finite Difference Discretization**

A typical diffusion equation

$$u\_l = \nabla \cdot (\alpha(u)\nabla u) + f(u),$$

can be discretized by (e.g.) a Backward Euler scheme, which in 2D can be written

$$\left[D\_t^-u = D\_x \overline{\alpha(u)}^\chi D\_x u + D\_y \overline{\alpha(u)}^\chi D\_y u + f(u)\right]\_{i,j}^n \dots$$

We do not dive into the details of handling boundary conditions now. Dirichlet and Neumann conditions are handled as in corresponding linear, variable-coefficient diffusion problems.

Writing the scheme out, putting the unknown values on the left-hand side and known values on the right-hand side, and introducing x D y D <sup>h</sup> to save some writing, one gets

$$\begin{split} u\_{i,j}^{n} - \frac{\Delta t}{h^{2}} \Big( \frac{1}{2} (\alpha(u\_{i,j}^{n}) + \alpha(u\_{i+1,j}^{n}))(u\_{i+1,j}^{n} - u\_{i,j}^{n}) \\ - \frac{1}{2} (\alpha(u\_{i-1,j}^{n}) + \alpha(u\_{i,j}^{n}))(u\_{i,j}^{n} - u\_{i-1,j}^{n}) \\ + \frac{1}{2} (\alpha(u\_{i,j}^{n}) + \alpha(u\_{i,j+1}^{n}))(u\_{i,j+1}^{n} - u\_{i,j}^{n}) \\ - \frac{1}{2} (\alpha(u\_{i,j-1}^{n}) + \alpha(u\_{i,j}^{n}))(u\_{i,j}^{n} - u\_{i-1,j-1}^{n}) \Big) - \Delta t f(u\_{i,j}^{n}) = u\_{i,j}^{n-1}. \end{split}$$

This defines a nonlinear algebraic system on the form A.u/u D b.u/.

**Picard iteration** The most recently computed values u of u<sup>n</sup> can be used in ˛ and <sup>f</sup> for a Picard iteration, or equivalently, we solve A.u/u D b.u/. The result is a linear system of the same type as arising from ut Dr .˛.x/ru/ <sup>C</sup> f .x;t/.

The Picard iteration scheme can also be expressed in operator notation:

$$\left[D\_{\boldsymbol{u}}^{-}\boldsymbol{u} = D\_{\boldsymbol{x}}\overline{\boldsymbol{\alpha}(\boldsymbol{u}^{-})}^{\boldsymbol{\chi}}D\_{\boldsymbol{x}}\boldsymbol{u} + D\_{\boldsymbol{y}}\overline{\boldsymbol{\alpha}(\boldsymbol{u}^{-})}^{\boldsymbol{y}}D\_{\boldsymbol{y}}\boldsymbol{u} + f(\boldsymbol{u}^{-})\right]\_{\boldsymbol{i},j}^{\boldsymbol{n}}\dots$$

**Newton's method** As always, Newton's method is technically more involved than Picard iteration. We first define the nonlinear algebraic equations to be solved, drop the superscript n (use u for u<sup>n</sup>), and introduce u.1/ for u<sup>n</sup><sup>1</sup>:

$$F\_{i,j} = u\_{i,j} - \frac{\Delta t}{h^2} \left( \frac{1}{2} (\alpha(u\_{i,j}) + \alpha(u\_{i+1,j}))(u\_{i+1,j} - u\_{i,j}) \right)$$

$$\begin{split} -\frac{1}{2} (\alpha(u\_{i-1,j}) + \alpha(u\_{i,j}))(u\_{i,j} - u\_{i-1,j}) \\ +\frac{1}{2} (\alpha(u\_{i,j}) + \alpha(u\_{i,j+1}))(u\_{i,j+1} - u\_{i,j}) \\ -\frac{1}{2} (\alpha(u\_{i,j-1}) + \alpha(u\_{i,j}))(u\_{i,j} - u\_{i-1,j-1}) \end{split}$$

$$-\Delta t \ f(u\_{i,j}) - u\_{i,j}^{(1)} = 0$$

It is convenient to work with two indices i and j in 2D finite difference discretizations, but it complicates the derivation of the Jacobian, which then gets four indices. (Make sure you really understand the 1D version of this problem as treated in Sect. 5.4.1.) The left-hand expression of an equation Fi;j <sup>D</sup> <sup>0</sup> is to be differentiated with respect to each of the unknowns ur;s (recall that this is short notation for un r;s ), <sup>r</sup> <sup>2</sup> *<sup>I</sup>*<sup>x</sup> , <sup>s</sup> <sup>2</sup> *<sup>I</sup>*y:

$$J\_{i,j,r,s} = \frac{\partial F\_{i,j}}{\partial u\_{r,s}} \dots$$

The Newton system to be solved in each iteration can be written as

$$\sum\_{r \in \mathcal{I}\_\mathcal{X}} \sum\_{s \in \mathcal{I}\_\mathcal{Y}} J\_{i,j,r,s} \delta u\_{r,s} = -F\_{i,j}, \quad i \in \mathcal{I}\_\mathcal{X}, \ j \in \mathcal{I}\_\mathcal{Y}.$$

Given i and j , only a few r and s indices give nonzero contribution to the Jacobian since Fi;j contains ui˙1;j , ui;j ˙<sup>1</sup>, and ui;j . This means that Ji;j;r;s has nonzero contributions only if <sup>r</sup> D <sup>i</sup> ˙ <sup>1</sup>, <sup>s</sup> D <sup>j</sup> ˙ <sup>1</sup>, as well as <sup>r</sup> D <sup>i</sup> and <sup>s</sup> D <sup>j</sup> . The corresponding terms in Ji;j;r;s are Ji;j;i1;j , Ji;j;iC1;j , Ji;j;i;j <sup>1</sup>, Ji;j;i;j C<sup>1</sup> and Ji;j;i;j . Therefore, the left-hand side of the Newton system, P r P <sup>s</sup> Ji;j;r;sıur;s collapses to

$$\begin{aligned} J\_{i,j,r,s} \delta u\_{r,s} &= J\_{i,j,i,j} \delta u\_{i,j} + J\_{i,j,i-1,j} \delta u\_{i-1,j} + J\_{i,j,i+1,j} \delta u\_{i+1,j} + J\_{i,j,i,j-1} \delta u\_{i,j-1} \\ &+ J\_{i,j,i,j+1} \delta u\_{i,j+1} .\end{aligned}$$

The specific derivatives become

$$\begin{split} J\_{i,j,i-1,j} &= \frac{\partial F\_{i,j}}{\partial u\_{i-1,j}} \\ &= \frac{\Delta t}{h^2} (\alpha'(u\_{i-1,j})(u\_{i,j} - u\_{i-1,j}) + \alpha(u\_{i-1,j})(-1)), \\ J\_{i,j,i+1,j} &= \frac{\partial F\_{i,j}}{\partial u\_{i+1,j}} \\ &= \frac{\Delta t}{h^2} (-\alpha'(u\_{i+1,j})(u\_{i+1,j} - u\_{i,j}) - \alpha(u\_{i-1,j})), \end{split}$$

$$\begin{split} J\_{i,j,i,j-1} &= \frac{\partial F\_{i,j}}{\partial u\_{i,j-1}} \\ &= \frac{\Delta t}{h^2} (\alpha'(u\_{i,j-1})(u\_{i,j} - u\_{i,j-1}) + \alpha(u\_{i,j-1})(-1)), \\ J\_{i,j,i,j+1} &= \frac{\partial F\_{i,j}}{\partial u\_{i,j+1}} \\ &= \frac{\Delta t}{h^2} (-\alpha'(u\_{i,j+1})(u\_{i,j+1} - u\_{i,j}) - \alpha(u\_{i,j-1})). \end{split}$$

The Ji;j;i;j entry has a few more terms and is left as an exercise. Inserting the most recent approximation u for u in the J and F formulas and then forming J ıu D <sup>F</sup> gives the linear system to be solved in each Newton iteration. Boundary conditions will affect the formulas when any of the indices coincide with a boundary value of an index.

#### **5.5.2 Continuation Methods**

Picard iteration or Newton's method may diverge when solving PDEs with severe nonlinearities. Relaxation with !<1 may help, but in highly nonlinear problems it can be necessary to introduce a *continuation parameter* in the problem: D <sup>0</sup> gives a version of the problem that is easy to solve, while D <sup>1</sup> is the target problem. The idea is then to increase in steps, 0 <sup>D</sup> 0; 1 <sup>&</sup>lt; < n <sup>D</sup> <sup>1</sup>, and use the solution from the problem with i<sup>1</sup> as initial guess for the iterations in the problem corresponding to i .

The continuation method is easiest to understand through an example. Suppose we intend to solve

$$-\nabla \cdot (||\nabla u||^q \nabla u) = f,$$

which is an equation modeling the flow of a non-Newtonian fluid through a channel or pipe. For <sup>q</sup> D <sup>0</sup> we have the Poisson equation (corresponding to a Newtonian fluid) and the problem is linear. A typical value for pseudo-plastic fluids may be qn D 0:8. We can introduce the continuation parameter <sup>2</sup> Œ0; 1 such that <sup>q</sup> D qn. Let <sup>f</sup>`g<sup>n</sup> `D<sup>0</sup> be the sequence of values in Œ0; 1, with corresponding <sup>q</sup> values fq`g<sup>n</sup> `D<sup>0</sup>. We can then solve a sequence of problems

$$-\nabla \cdot \left( ||\nabla u^{\ell}||\_{\ell}^{q} \nabla u^{\ell} \right) = f, \quad \ell = 0, \ldots, n,$$

where the initial guess for iterating on u` is the previously computed solution u`<sup>1</sup>. If a particular ` leads to convergence problems, one may try a smaller increase in : <sup>D</sup> <sup>1</sup> <sup>2</sup> .`<sup>1</sup> <sup>C</sup> `/, and repeat halving the step in until convergence is reestablished.

#### **5.6 Operator Splitting Methods**

Operator splitting is a natural and old idea. When a PDE or system of PDEs contains different terms expressing different physics, it is natural to use different numerical methods for different physical processes. This can optimize and simplify the overall solution process. The idea was especially popularized in the context of the Navier-Stokes equations and reaction-diffusion PDEs. Common names for the technique are *operator splitting*, *fractional step* methods, and *split-step* methods. We shall stick to the former name. In the context of nonlinear differential equations, operator splitting can be used to isolate nonlinear terms and simplify the solution methods.

A related technique, often known as dimensional splitting or alternating direction implicit (ADI) methods, is to split the spatial dimensions and solve a 2D or 3D problem as two or three consecutive 1D problems, but this type of splitting is not to be further considered here.

#### **5.6.1 Ordinary Operator Splitting for ODEs**

Consider first an ODE where the right-hand side is split into two terms:

$$u' = f\_0(u) + f\_1(u) \,. \tag{5.71}$$

In case f0 and f1 are linear functions of <sup>u</sup>, f0 <sup>D</sup> au and f1 <sup>D</sup> bu, we have u.t/ <sup>D</sup> Ie.aCb/t , if u.0/ <sup>D</sup> <sup>I</sup> . When going one time step of length t from tn to tnC<sup>1</sup>, we have

$$
\mu(t\_{n+1}) = \mu(t\_n) e^{(a+b)\Delta t} \, .
$$

This expression can be also be written as

$$
\mu(t\_{n+1}) = \mu(t\_n) e^{a\Delta t} e^{b\Delta t},
$$

or

$$
\mu^\* = \mu(t\_n) e^{a\Delta t},\tag{5.72}
$$

$$
u(t\_{n+1}) = 
u^\* e^{b\Delta t} \,. \tag{5.73}$$

The first step (5.72) means solving <sup>u</sup><sup>0</sup> <sup>D</sup> f0 over a time interval t with u.tn/ as start value. The second step (5.73) means solving <sup>u</sup><sup>0</sup> <sup>D</sup> f1 over a time interval t with the value at the end of the first step as start value. That is, we progress the solution in two steps and solve two ODEs <sup>u</sup><sup>0</sup> <sup>D</sup> f0 and <sup>u</sup><sup>0</sup> <sup>D</sup> f1. The order of the equations is not important. From the derivation above we see that solving <sup>u</sup><sup>0</sup> <sup>D</sup> f1 prior to <sup>u</sup><sup>0</sup> <sup>D</sup> f0 can equally well be done.

The technique is exact if the ODEs are linear. For nonlinear ODEs it is only an approximate method with error t. The technique can be extended to an arbitrary number of steps; i.e., we may split the PDE system into any number of subsystems. Examples will illuminate this principle.

#### **5.6.2 Strang Splitting for ODEs**

The accuracy of the splitting method in Sect. 5.6.1 can be improved from *O*.t/ to *<sup>O</sup>*.t 2/ using so-called *Strang splitting*, where we take half a step with the f0 operator, a full step with the f1 operator, and finally half another step with the f0 operator. During a time interval t the algorithm can be written as follows.

$$\begin{aligned} \frac{du^\*}{dt} &= f\_0(u^\*), \quad u^\*(t\_n) = u(t\_n), \quad t \in \left[t\_n, t\_n + \frac{1}{2}\Delta t\right], \\\frac{du^{\*\*\*}}{dt} &= f\_1(u^{\*\*\*}), \quad u^{\*\*\*}(t\_n) = u^\*\left(t\_{n+\frac{1}{2}}\right), \quad t \in [t\_n, t\_n + \Delta t], \\\frac{du^{\*\*\*}}{dt} &= f\_0(u^{\*\*\*}), \quad u^{\*\*\*}\left(t\_{n+\frac{1}{2}}\right) = u^{\*\*\*}(t\_{n+1}), \quad t \in \left[t\_n + \frac{1}{2}\Delta t, t\_n + \Delta t\right]. \end{aligned}$$

The global solution is set as u.tnC1/ <sup>D</sup> <sup>u</sup> .tnC1/.

There is no use in combining higher-order methods with ordinary splitting since the error due to splitting is *O*.t/, but for Strang splitting it makes sense to use schemes of order *<sup>O</sup>*.t 2/.

With the notation introduced for Strang splitting, we may express ordinary firstorder splitting as

$$\begin{aligned} \frac{d\boldsymbol{u}^{\*}}{dt} &= f\_{0}(\boldsymbol{u}^{\*}), \quad \boldsymbol{u}^{\*}(t\_{n}) = \boldsymbol{u}(t\_{n}), \quad \boldsymbol{t} \in [t\_{n}, t\_{n} + \Delta t],\\ \frac{d\boldsymbol{u}^{\*\*}}{dt} &= f\_{1}(\boldsymbol{u}^{\*\*}), \quad \boldsymbol{u}^{\*\*}(t\_{n}) = \boldsymbol{u}^{\*}(t\_{n+1}), \quad \boldsymbol{t} \in [t\_{n}, t\_{n} + \Delta t], \end{aligned}$$

with global solution set as u.tnC1/ <sup>D</sup> <sup>u</sup> .tnC1/.

#### **5.6.3 Example: Logistic Growth**

Let us split the (scaled) logistic equation

$$u' = u(1 - u), \quad u(0) = 0.1,$$

with solution <sup>u</sup> <sup>D</sup> .9e<sup>t</sup> C 1/<sup>1</sup>, into

$$u' = u - u^2 = f\_0(u) + f\_1(u), \quad f\_0(u) = u, \quad f\_1(u) = -u^2 \dots$$

We solve <sup>u</sup><sup>0</sup> D f0.u/ and <sup>u</sup><sup>0</sup> D f1.u/ by a Forward Euler step. In addition, we add a method where we solve <sup>u</sup><sup>0</sup> D f0.u/ analytically, since the equation is actually <sup>u</sup><sup>0</sup> D <sup>u</sup> with solution <sup>e</sup><sup>t</sup> . The software that accompanies the following methods is the file split\_logistic.py.

**Splitting techniques** Ordinary splitting takes a Forward Euler step for each of the ODEs according to

$$\frac{u^{\*,n+1} - u^{\*,n}}{\Delta t} = f\_0(u^{\*,n}), \quad u^{\*,n} = u(t\_n), \quad t \in [t\_n, t\_n + \Delta t], \tag{5.74}$$

$$\frac{u^{\*\*,n+1} - u^{\*\*,n}}{\Delta t} = f\_1(u^{\*\*,n}), \quad u^{\*\*,n} = u^{\*,n+1}, \quad t \in [t\_n, t\_n + \Delta t], \tag{5.75}$$

with u.tnC1/ <sup>D</sup> <sup>u</sup> ;nC<sup>1</sup>.

Strang splitting takes the form

$$u^{\*,n+\frac{1}{2}} - u^{\*,n} = f\_0(u^{\*,n}), \quad u^{\*,n} = u(t\_n), \ t \in \left[t\_n, t\_n + \frac{1}{2}\Delta t\right],\qquad(5.76)$$

$$\frac{u^{\*\*\*,n+1} - u^{\*\*\*,n}}{\Delta t} = f\_1(u^{\*\*\*,n}), \quad u^{\*\*\*,n} = u^{\*,n+\frac{1}{2}}, \ t \in [t\_n, t\_n + \Delta t], \quad (5.77)$$

$$\begin{aligned} \frac{u^{\*\*,n+1} - u^{\*\*,n+\frac{1}{2}}}{\frac{1}{2}\Delta t} &= f\_0\left(u^{\*\*,n+\frac{1}{2}}\right), \quad u^{\*\*,n+\frac{1}{2}} = u^{\*\*,n+1},\\ t &\in \left[t\_n + \frac{1}{2}\Delta t, t\_n + \Delta t\right]. \end{aligned} \tag{5.78}$$

**Verbose implementation** The following function computes four solutions arising from the Forward Euler method, ordinary splitting, Strang splitting, as well as Strang splitting with exact treatment of <sup>u</sup><sup>0</sup> <sup>D</sup> f0.u/:

```
import numpy as np
def solver(dt, T, f, f_0, f_1):
    """
    Solve u'=f by the Forward Euler method and by ordinary and
    Strang splitting: f(u) = f_0(u) + f_1(u).
    """
    Nt = int(round(T/float(dt)))
    t = np.linspace(0, Nt*dt, Nt+1)
    u_FE = np.zeros(len(t))
    u_split1 = np.zeros(len(t)) # 1st-order splitting
    u_split2 = np.zeros(len(t)) # 2nd-order splitting
    u_split3 = np.zeros(len(t)) # 2nd-order splitting w/exact f_0
    # Set initial values
    u_FE[0] = 0.1
    u_split1[0] = 0.1
    u_split2[0] = 0.1
    u_split3[0] = 0.1
    for n in range(len(t)-1):
        # Forward Euler method
        u_FE[n+1] = u_FE[n] + dt*f(u_FE[n])
        # --- Ordinary splitting ---
        # First step
        u_s_n = u_split1[n]
        u_s = u_s_n + dt*f_0(u_s_n)
        # Second step
        u_ss_n = u_s
        u_ss = u_ss_n + dt*f_1(u_ss_n)
        u_split1[n+1] = u_ss
        # --- Strang splitting ---
        # First step
        u_s_n = u_split2[n]
        u_s = u_s_n + dt/2.*f_0(u_s_n)
```

```
# Second step
    u_sss_n = u_s
    u_sss = u_sss_n + dt*f_1(u_sss_n)
    # Third step
    u_ss_n = u_sss
    u_ss = u_ss_n + dt/2.*f_0(u_ss_n)
    u_split2[n+1] = u_ss
    # --- Strang splitting using exact integrator for u'=f_0 ---
    # First step
    u_s_n = u_split3[n]
    u_s = u_s_n*np.exp(dt/2.) # exact
    # Second step
    u_sss_n = u_s
    u_sss = u_sss_n + dt*f_1(u_sss_n)
    # Third step
    u_ss_n = u_sss
    u_ss = u_ss_n*np.exp(dt/2.) # exact
    u_split3[n+1] = u_ss
return u_FE, u_split1, u_split2, u_split3, t
```
**Compact implementation** We have used quite many lines for the steps in the splitting methods. Many will prefer to condense the code a bit, as done here:

```
# Ordinary splitting
u_s = u_split1[n] + dt*f_0(u_split1[n])
u_split1[n+1] = u_s + dt*f_1(u_s)
# Strang splitting
u_s = u_split2[n] + dt/2.*f_0(u_split2[n])
u_sss = u_s + dt*f_1(u_s)
u_split2[n+1] = u_sss + dt/2.*f_0(u_sss)
# Strang splitting using exact integrator for u'=f_0
u_s = u_split3[n]*np.exp(dt/2.) # exact
u_ss = u_s + dt*f_1(u_s)
u_split3[n+1] = u_ss*np.exp(dt/2.)
```
**Results** Figure 5.3 shows that the impact of splitting is significant. Interestingly, however, the Forward Euler method applied to the entire problem directly is much more accurate than any of the splitting schemes. We also see that Strang splitting is definitely more accurate than ordinary splitting and that it helps a bit to use an exact solution of <sup>u</sup><sup>0</sup> D f0.u/. With a large time step (t D 0:2, left plot in Fig. 5.3), the asymptotic values are off by 20–30 %. A more reasonable time step (t D 0:05, right plot in Fig. 5.3) gives better results, but still the asymptotic values are up to 10 % wrong.

As technique for solving nonlinear ODEs, we realize that the present case study is not particularly promising, as the Forward Euler method both linearizes the original problem and provides a solution that is much more accurate than any of the splitting techniques. In complicated multi-physics settings, on the other hand, splitting may be the only feasible way to go, and sometimes you really need to apply different numerics to different parts of a PDE problem. But in very simple problems, like the logistic ODE, splitting is just an inferior technique. Still, the logistic

**Fig. 5.3** Effect of ordinary and Strang splitting for the logistic equation

ODE is ideal for introducing all the mathematical details and for investigating the behavior.

#### **5.6.4 Reaction-Diffusion Equation**

Consider a diffusion equation coupled to chemical reactions modeled by a nonlinear term f .u/:

$$\frac{\partial u}{\partial t} = \alpha \nabla^2 u + f(u).$$

This is a physical process composed of two individual processes: u is the concentration of a substance that is locally generated by a chemical reaction f .u/, while u is spreading in space because of diffusion. There are obviously two time scales: one for the chemical reaction and one for diffusion. Typically, fast chemical reactions require much finer time stepping than slower diffusion processes. It could therefore be advantageous to split the two physical effects in separate models and use different numerical methods for the two.

A natural spitting in the present case is

$$\frac{\partial u^\*}{\partial t} = \alpha \nabla^2 u^\*,\tag{5.79}$$

$$\frac{\partial u^{\*\*}}{\partial t} = f(u^{\*\*})\,. \tag{5.80}$$

Looking at these familiar problems, we may apply a rule (implicit) scheme for (5.79) over one time step and avoid dealing with nonlinearities by applying an explicit scheme for (5.80) over the same time step.

Suppose we have some solution u at time level tn. For flexibility, we define a - method for the diffusion part (5.79) by

$$[D\_l u^\* = \alpha (D\_x D\_x u^\* + D\_y D\_y u^\*)]^{n+\theta} \dots$$

We use u<sup>n</sup> as initial condition for u .

The reaction part, which is defined at each mesh point (without coupling values in different mesh points), can employ any scheme for an ODE. Here we use an Adams-Bashforth method of order 2. Recall that the overall accuracy of the splitting method is maximum *<sup>O</sup>*.t 2/ for Strang splitting, otherwise it is just *<sup>O</sup>*.t/. Higherorder methods for ODEs will therefore be a waste of work. The 2nd-order Adams-Bashforth method reads

$$u\_{i,j}^{\*\*,n+1} = u\_{i,j}^{\*\*,n} + \frac{1}{2} \Delta t \left( \Im f(u\_{i,j}^{\*\*,n}, t\_n) - f(u\_{i,j}^{\*\*,n-1}, t\_{n-1}) \right) \,. \tag{5.81}$$

We can use a Forward Euler step to start the method, i.e, compute u ;1 i;j . The algorithm goes like this:


We may use a much smaller time step when solving the reaction part, adapted to the dynamics of the problem <sup>u</sup><sup>0</sup> D f .u/. This gives great flexibility in splitting methods.

#### **5.6.5 Example: Reaction-Diffusion with Linear Reaction Term**

The methods above may be explored in detail through a specific computational example in which we compute the convergence rates associated with four different solution approaches for the reaction-diffusion equation with a linear reaction term, i.e. f .u/ D bu. The methods comprise solving without splitting (just straight Forward Euler), ordinary splitting, first order Strang splitting, and second order Strang splitting. In all four methods, a standard centered difference approximation is used for the spatial second derivative. The methods share the error model <sup>E</sup> D C hr , while differing in the step h (being either x<sup>2</sup> or x) and the convergence rate r (being either 1 or 2).

All code commented below is found in the file split\_diffu\_react.py. When executed, a function convergence\_rates is called, from which all convergence rate computations are handled:

```
def convergence_rates(scheme='diffusion'):
    F = 0.5 # Upper limit for FE (stability). For CN, this
                # limit does not apply, but for simplicity, we
                # choose F = 0.5 as the initial F value.
    T = 1.2
    a = 3.5
    b = 1
    L = 1.5
    k = np.pi/L
```

```
def exact(x, t):
    '''exact sol. to: du/dt = a*d^2u/dx^2 - b*u'''
    return np.exp(-(a*k**2 + b)*t) * np.sin(k*x)
def f(u, t):
    return -b*u
def I(x):
    return exact(x, 0)
global error # error computed in the user action function
error = 0
# Convergence study
def action(u, x, t, n):
    global error
    if n == 1: # New simulation, - reset error
        error = 0
    else:
        error = max(error, np.abs(u - exact(x, t[n])).max())
E = []
h = []
Nx_values = [10, 20, 40, 80] # i.e., dx halved each time
for Nx in Nx_values:
    dx = L/Nx
    if scheme == 'Strang_splitting_2ndOrder':
        print 'Strang splitting with 2nd order schemes...'
        # In this case, E = C*h**r (with r = 2) and since
        # h = dx = K*dt, the ratio dt/dx must be constant.
        # To fulfill this demand, we must let F change
        # when dx changes. From F = a*dt/dx**2, it follows
        # that halving dx AND doubling F assures dt/dx const.
        # Initially, we simply choose F = 0.5.
        dt = F/a*dx**2
        #print 'dt/dx:', dt/dx
        Nt = int(round(T/float(dt)))
        t = np.linspace(0, Nt*dt, Nt+1) # global time
        Strang_splitting_2ndOrder(I=I, a=a, b=b, f=f, L=L, dt=dt,
                                  dt_Rfactor=1, F=F, t=t, T=T,
                                  user_action=action)
        h.append(dx)
        # prepare for next iteration (make F match dx/2)
        F = F*2 # assures dt/dx const. when dx = dx/2
    else:
        # In these cases, E = C*h**r (with r = 1) and since
        # h = dx**2 = K*dt, the ratio dt/dx**2 must be constant.
        # This is fulfilled by choosing F = 0.5 (for FE stability)
        # and make sure that F, dx and dt comply to F = a*dt/dx**2.
        dt = F/a*dx**2
        Nt = int(round(T/float(dt)))
        t = np.linspace(0, Nt*dt, Nt+1) # global time
```

```
if scheme == 'diffusion':
                print 'FE on whole eqn...'
                diffusion_theta(I, a, f, L, dt, F, t, T,
                                step_no=0, theta=0,
                                u_L=0, u_R=0, user_action=action)
                h.append(dx**2)
            elif scheme == 'ordinary_splitting':
                print 'Ordinary splitting...'
                ordinary_splitting(I=I, a=a, b=b, f=f, L=L, dt=dt,
                                    dt_Rfactor=1, F=F, t=t, T=T,
                                    user_action=action)
                h.append(dx**2)
            elif scheme == 'Strang_splitting_1stOrder':
                print 'Strang splitting with 1st order schemes...'
                Strang_splitting_1stOrder(I=I, a=a, b=b, f=f, L=L, dt=dt,
                                           dt_Rfactor=1, F=F, t=t, T=T,
                                           user_action=action)
                h.append(dx**2)
            else:
                print 'Unknown scheme requested!'
                sys.exit(0)
            #print 'dt/dx**2:', dt/dx**2
        E.append(error)
        Nx *= 2 # Nx doubled gives dx/2
    print 'E:', E
    print 'h:', h
    # Convergence rates
    r = [np.log(E[i]/E[i-1])/np.log(h[i]/h[i-1])
         for i in range(1,len(Nx_values))]
    print 'Computed rates:', r
if __name__ == '__main__':
    schemes = ['diffusion',
               'ordinary_splitting',
               'Strang_splitting_1stOrder',
               'Strang_splitting_2ndOrder']
    for scheme in schemes:
        convergence_rates(scheme=scheme)
```
Now, with respect to the error (<sup>E</sup> D C hr ), the Forward Euler scheme, the ordinary splitting scheme and first order Strang splitting scheme are all first order (<sup>r</sup> D <sup>1</sup>), with a step <sup>h</sup> D x<sup>2</sup> D <sup>K</sup>1t, where <sup>K</sup> is some constant. This implies that the *ratio* t x<sup>2</sup> must be held constant during convergence rate calculations. Furthermore, the Fourier number <sup>F</sup> D ˛t x<sup>2</sup> is upwards limited to <sup>F</sup> <sup>D</sup> 0:5, being the stability limit with explicit schemes. Thus, in these cases, we use the fixed value of F and a given (but changing) spatial resolution x to compute the corresponding value of t according to the expression for F. This assures that t x<sup>2</sup> is kept constant. The loop in convergence\_rates runs over a chosen set of grid points (Nx\_values) which gives a doubling of spatial resolution with each iteration (x is halved).

For the second order Strang splitting scheme, we have <sup>r</sup> D <sup>2</sup> and a step <sup>h</sup> D x D <sup>K</sup>1t, where <sup>K</sup> again is some constant. In this case, it is thus the ratio t x that must be held constant during the convergence rate calculations. From the expression for F , it is clear then that F must change with each halving of x. In fact, if F is doubled each time x is halved, the ratio t x will be constant (this follows, e.g., from the expression for F). This is utilized in our code.

A solver diffusion\_theta is used in each of the four solution approaches:

```
def diffusion_theta(I, a, f, L, dt, F, t, T, step_no, theta=0.5,
                    u_L=0, u_R=0, user_action=None):
    """
    Full solver for the model problem using the theta-rule
    difference approximation in time (no restriction on F,
    i.e., the time step when theta >= 0.5). Vectorized
    implementation and sparse (tridiagonal) coefficient matrix.
    Note that t always covers the whole global time interval, whether
    splitting is the case or not. T, on the other hand, is
    the end of the global time interval if there is no split,
    but if splitting, we use T=dt. When splitting, step_no
    keeps track of the time step number (for lookup in t).
    """
    Nt = int(round(T/float(dt)))
    dx = np.sqrt(a*dt/F)
    Nx = int(round(L/dx))
    x = np.linspace(0, L, Nx+1) # Mesh points in space
    # Make sure dx and dt are compatible with x and t
    dx = x[1] - x[0]
    dt = t[1] - t[0]
    u = np.zeros(Nx+1) # solution array at t[n+1]
    u_1 = np.zeros(Nx+1) # solution at t[n]
    # Representation of sparse matrix and right-hand side
    diagonal = np.zeros(Nx+1)
    lower = np.zeros(Nx)
    upper = np.zeros(Nx)
    b = np.zeros(Nx+1)
    # Precompute sparse matrix (scipy format)
    Fl = F*theta
    Fr = F*(1-theta)
    diagonal[:] = 1 + 2*Fl
    lower[:] = -Fl #1
    upper[:] = -Fl #1
    # Insert boundary conditions
    diagonal[0] = 1
    upper[0] = 0
    diagonal[Nx]=1
    lower[-1] = 0
```

```
diags = [0, -1, 1]
A = scipy.sparse.diags(
    diagonals=[diagonal, lower, upper],
    offsets=[0, -1, 1], shape=(Nx+1, Nx+1),
    format='csr')
#print A.todense()
# Allow f to be None or 0
if f is None or f == 0:
    f = lambda x, t: np.zeros((x.size)) \
        if isinstance(x, np.ndarray) else 0
# Set initial condition
if isinstance(I, np.ndarray): # I is an array
    u_1 = np.copy(I)
else: # I is a function
    for i in range(0, Nx+1):
        u_1[i] = I(x[i])
if user_action is not None:
    user_action(u_1, x, t, step_no+0)
# Time loop
for n in range(0, Nt):
    b[1:-1] = u_1[1:-1] + \
              Fr*(u_1[:-2] - 2*u_1[1:-1] + u_1[2:]) + \
              dt*theta*f(u_1[1:-1], t[step_no+n+1]) + \
              dt*(1-theta)*f(u_1[1:-1], t[step_no+n])
    b[0] = u_L; b[-1] = u_R # boundary conditions
    u[:] = scipy.sparse.linalg.spsolve(A, b)
    if user_action is not None:
        user_action(u, x, t, step_no+(n+1))
    # Update u_1 before next step
    u_1, u = u, u_1
# u is now contained in u_1 (swapping)
return u_1
```
For the no splitting approach with Forward Euler in time, this solver handles both the diffusion and the reaction term. When splitting, diffusion\_theta takes care of the diffusion term only, while the reaction term is handled either by a Forward Euler scheme in reaction\_FE, or by a second order Adams-Bashforth scheme from Odespy. The reaction\_FE function covers one complete time step dt during ordinary splitting, while Strang splitting (both first and second order) applies it with dt/2 twice during each time step dt. Since the reaction term typically represents a much faster process than the diffusion term, a further refinement of the time step is made possible in reaction\_FE. It was implemented as

```
def reaction_FE(I, f, L, Nx, dt, dt_Rfactor, t, step_no,
                user_action=None):
    """Reaction solver, Forward Euler method.
    Note the at t covers the whole global time interval.
    dt is either one complete,or one half, of the step in the
    diffusion part, i.e. there is a local time interval
    [0, dt] or [0, dt/2] that the reaction_FE
    deals with each time it is called. step_no keeps
    track of the (global) time step number (required
    for lookup in t).
    """
    u = np.copy(I)
    dt_local = dt/float(dt_Rfactor)
    Nt_local = int(round(dt/float(dt_local)))
    x = np.linspace(0, L, Nx+1)
    for n in range(Nt_local):
        time = t[step_no] + n*dt_local
        u[1:Nx] = u[1:Nx] + dt_local*f(u[1:Nx], time)
    # BC already inserted in diffusion step, i.e. no action here
    return u
```
With the ordinary splitting approach, each time step dt is covered twice. First computing the impact of the reaction term, then the contribution from the diffusion term:

```
def ordinary_splitting(I, a, b, f, L, dt,
                       dt_Rfactor, F, t, T,
                       user_action=None):
    '''1st order scheme, i.e. Forward Euler is enough for both
    the diffusion and the reaction part. The time step dt is
    given for the diffusion step, while the time step for the
    reaction part is found as dt/dt_Rfactor, where dt_Rfactor >= 1.
    '''
    Nt = int(round(T/float(dt)))
    dx = np.sqrt(a*dt/F)
    Nx = int(round(L/dx))
    x = np.linspace(0, L, Nx+1) # Mesh points in space
    u = np.zeros(Nx+1)
    # Set initial condition u(x,0) = I(x)
    for i in range(0, Nx+1):
        u[i] = I(x[i])
    # In the following loop, each time step is "covered twice",
    # first for reaction, then for diffusion
    for n in range(0, Nt):
        # Reaction step (potentially many smaller steps within dt)
        u_s = reaction_FE(I=u, f=f, L=L, Nx=Nx,
                        dt=dt, dt_Rfactor=dt_Rfactor,
                        t=t, step_no=n,
                        user_action=None)
```

```
u = diffusion_theta(I=u_s, a=a, f=0, L=L, dt=dt, F=F,
                          t=t, T=dt, step_no=n, theta=0,
                          u_L=0, u_R=0, user_action=None)
    if user_action is not None:
        user_action(u, x, t, n+1)
return
```
For the two Strang splitting approaches, each time step dt is handled by first computing the reaction step for (the first) dt/2, followed by a diffusion step dt, before the reaction step is treated once again for (the remaining) dt/2. Since first order Strang splitting is no better than first order accurate, both the reaction and diffusion steps are computed explicitly. The solver was implemented as

```
def Strang_splitting_1stOrder(I, a, b, f, L, dt, dt_Rfactor,
                              F, t, T, user_action=None):
    '''Strang splitting while still using FE for the reaction
    step and for the diffusion step. Gives 1st order scheme.
    The time step dt is given for the diffusion step, while
    the time step for the reaction part is found as
    0.5*dt/dt_Rfactor, where dt_Rfactor >= 1. Introduce an
    extra time mesh t2 for the reaction part, since it steps dt/2.
    '''
    Nt = int(round(T/float(dt)))
    t2 = np.linspace(0, Nt*dt, (Nt+1)+Nt) # Mesh points in diff
    dx = np.sqrt(a*dt/F)
    Nx = int(round(L/dx))
    x = np.linspace(0, L, Nx+1)
    u = np.zeros(Nx+1)
    # Set initial condition u(x,0) = I(x)
    for i in range(0, Nx+1):
        u[i] = I(x[i])
    for n in range(0, Nt):
        # Reaction step (1/2 dt: from t_n to t_n+1/2)
        # (potentially many smaller steps within dt/2)
        u_s = reaction_FE(I=u, f=f, L=L, Nx=Nx,
                        dt=dt/2.0, dt_Rfactor=dt_Rfactor,
                        t=t2, step_no=2*n,
                        user_action=None)
        # Diffusion step (1 dt: from t_n to t_n+1)
        u_sss = diffusion_theta(I=u_s, a=a, f=0, L=L, dt=dt, F=F,
                              t=t, T=dt, step_no=n, theta=0,
                              u_L=0, u_R=0, user_action=None)
        # Reaction step (1/2 dt: from t_n+1/2 to t_n+1)
        # (potentially many smaller steps within dt/2)
        u = reaction_FE(I=u_sss, f=f, L=L, Nx=Nx,
                        dt=dt/2.0, dt_Rfactor=dt_Rfactor,
                        t=t2, step_no=2*n+1,
                        user_action=None)
        if user_action is not None:
            user_action(u, x, t, n+1)
```
return

The second order version of the Strang splitting approach utilizes a second order Adams-Bashforth solver for the reaction part and a Crank-Nicolson scheme for the diffusion part. The solver has the same structure as the one for first order Strang splitting and was implemented as

```
def Strang_splitting_2ndOrder(I, a, b, f, L, dt, dt_Rfactor,
                              F, t, T, user_action=None):
    '''Strang splitting using Crank-Nicolson for the diffusion
    step (theta-rule) and Adams-Bashforth 2 for the reaction step.
    Gives 2nd order scheme. Introduce an extra time mesh t2 for
    the reaction part, since it steps dt/2.
    '''
    import odespy
    Nt = int(round(T/float(dt)))
    t2 = np.linspace(0, Nt*dt, (Nt+1)+Nt) # Mesh points in diff
    dx = np.sqrt(a*dt/F)
    Nx = int(round(L/dx))
    x = np.linspace(0, L, Nx+1)
    u = np.zeros(Nx+1)
    # Set initial condition u(x,0) = I(x)
    for i in range(0, Nx+1):
        u[i] = I(x[i])
    reaction_solver = odespy.AdamsBashforth2(f)
    for n in range(0, Nt):
        # Reaction step (1/2 dt: from t_n to t_n+1/2)
        # (potentially many smaller steps within dt/2)
        reaction_solver.set_initial_condition(u)
        t_points = np.linspace(0, dt/2.0, dt_Rfactor+1)
        u_AB2, t_ = reaction_solver.solve(t_points) # t_ not needed
        u_s = u_AB2[-1,:] # pick sol at last point in time
        # Diffusion step (1 dt: from t_n to t_n+1)
        u_sss = diffusion_theta(I=u_s, a=a, f=0, L=L, dt=dt, F=F,
                              t=t, T=dt, step_no=n, theta=0.5,
                              u_L=0, u_R=0, user_action=None)
        # Reaction step (1/2 dt: from t_n+1/2 to t_n+1)
        # (potentially many smaller steps within dt/2)
        reaction_solver.set_initial_condition(u_sss)
        t_points = np.linspace(0, dt/2.0, dt_Rfactor+1)
        u_AB2, t_ = reaction_solver.solve(t_points) # t_ not needed
        u = u_AB2[-1,:] # pick sol at last point in time
        if user_action is not None:
            user_action(u, x, t, n+1)
    return
```
When executing split\_diffu\_react.py, we find that the estimated convergence rates are as expected. The second order Strang splitting gives the least error (about 4e<sup>5</sup>) and has second order convergence (<sup>r</sup> D <sup>2</sup>), while the remaining three approaches have first order convergence (<sup>r</sup> D <sup>1</sup>).

#### **5.6.6 Analysis of the Splitting Method**

Let us address a linear PDE problem for which we can develop analytical solutions of the discrete equations, with and without splitting, and discuss these. Choosing f .u/ D ˇu for a constant <sup>ˇ</sup> gives a linear problem. We use the Forward Euler method for both the PDE and ODE problems.

We seek a 1D Fourier wave component solution of the problem, assuming homogeneous Dirichlet conditions at <sup>x</sup> D <sup>0</sup> and <sup>x</sup> D <sup>L</sup>:

$$u = e^{-\alpha k^2 t - \beta I} \sin kx, \quad k = \frac{\pi}{L} \dots$$

This component fits the 1D PDE problem (<sup>f</sup> D <sup>0</sup>). On complex form we can write

$$u = e^{-\alpha k^2 t - \beta t + ik\alpha},$$

where <sup>i</sup> D p<sup>1</sup> and the imaginary part is taken as the physical solution.

We refer to Sect. 3.3 and to the book [9] for a discussion of exact numerical solutions to diffusion and decay problems, respectively. The key idea is to search for solutions Aneikx and determine A. For the diffusion problem solved by a Forward Euler method one has

$$A = 1 - 4F\sin^p,$$

where <sup>F</sup> D ˛t=x<sup>2</sup> is the mesh Fourier number and <sup>p</sup> <sup>D</sup> kx=2 is a dimensionless number reflecting the spatial resolution (number of points per wave length in space). For the decay problem <sup>u</sup><sup>0</sup> D ˇu, we have <sup>A</sup> D <sup>1</sup> <sup>q</sup>, where <sup>q</sup> is a dimensionless parameter for the resolution in the decay problem: <sup>q</sup> D ˇt.

The original model problem can also be discretized by a Forward Euler scheme,

$$[D\_l^+u = \alpha D\_x D\_x u - \beta u]\_l^n \dots$$

Assuming Aneikx we find that

$$u\_i'' = (1 - 4F\sin^p - q)''\sin k\,\times\,\dots$$

We are particularly interested in what happens at one time step. That is,

$$u\_i^n = (1 - 4F\sin^2 p)u\_i^{n-1} \dots$$

In the two stage algorithm, we first compute the diffusion step

$$u\_i^{\*,n+1} = (1 - 4F\sin^2 p)u\_i^{n-1} \dots$$

Then we use this as input to the decay algorithm and arrive at

$$u^{\*\*,n+1} = (1-q)u^{\*,n+1} = (1-q)(1-4F\sin^2 p)u\_i^{n-1}.$$

The splitting approximation over one step is therefore

$$E = 1 - 4F\sin^p - q - (1 - q)(1 - 4F\sin^2 p) = -q(2 - F\sin^2 p) \dots$$

#### **5.7 Exercises**

#### **Problem 5.1: Determine if equations are nonlinear or not**

Classify each term in the following equations as linear or nonlinear. Assume that u, u, and <sup>p</sup> are unknown functions and that all other symbols are known quantities.

1. mu<sup>00</sup> <sup>C</sup> <sup>ˇ</sup>ju<sup>0</sup> ju<sup>0</sup> C cu D F .t/ 2. ut <sup>D</sup> ˛uxx 3. utt <sup>D</sup> <sup>c</sup><sup>2</sup>r2u 4. ut Dr .˛.u/ru/ <sup>C</sup> f .x; y/ 5. ut <sup>C</sup> f .u/x <sup>D</sup> <sup>0</sup> 6. <sup>u</sup><sup>t</sup> <sup>C</sup> <sup>u</sup> r<sup>u</sup> D r<sup>p</sup> <sup>C</sup> <sup>r</sup>r<sup>2</sup>u, r <sup>u</sup> <sup>D</sup> <sup>0</sup> (<sup>u</sup> is a vector field) 7. <sup>u</sup><sup>0</sup> D f .u; t/ 8. r2u D e<sup>u</sup>

Filename: nonlinear\_vs\_linear.

#### **Problem 5.2: Derive and investigate a generalized logistic model**

The logistic model for population growth is derived by assuming a nonlinear growth rate,

$$u' = a(u)u, \quad u(0) = I,\tag{5.82}$$

and the logistic model arises from the simplest possible choice of a.u/: r .u/ D %.1u=M /, where <sup>M</sup> is the maximum value of <sup>u</sup> that the environment can sustain, and % is the growth under unlimited access to resources (as in the beginning when u is small). The idea is that a.u/ % when <sup>u</sup> is small and that a.t/ ! <sup>0</sup> as <sup>u</sup> ! <sup>M</sup>.

An a.u/ that generalizes the linear choice is the polynomial form

$$a(u) = \varrho (1 - u/M)^p,\tag{5.83}$$

where p>0 is some real number.

a) Formulate a Forward Euler, Backward Euler, and a Crank-Nicolson scheme for (5.82).

*Hint* Use a geometric mean approximation in the Crank-Nicolson scheme: Œa.u/u<sup>n</sup>C1=2 a.un/unC<sup>1</sup>.


*Hint* You need to experiment to find what "infinite time" is (increases substantially with p) and what the appropriate tolerance is for testing the asymptotic limit.

e) Perform experiments with Newton and Picard iteration for the model (5.83). See how sensitive the number of iterations is to t and p.

Filename: logistic\_p.

#### **Problem 5.3: Experience the behavior of Newton's method**

The program Newton\_demo.py illustrates graphically each step in Newton's method and is run like


Use this program to investigate potential problems with Newton's method when solving e0:5x<sup>2</sup> cos.x/ <sup>D</sup> <sup>0</sup>. Try a starting point x0 <sup>D</sup> 0:8 and x0 <sup>D</sup> 0:85 and watch the different behavior. Just run


and repeat with 0.85 replaced by 0.8.

#### **Exercise 5.4: Compute the Jacobian of a** 2 -2 **system**

Write up the system (5.18)–(5.19) in the form F .u/ <sup>D</sup> <sup>0</sup>, <sup>F</sup> <sup>D</sup> .F0; F1/, <sup>u</sup> D .u0; u1/, and compute the Jacobian Ji;j <sup>D</sup> @Fi=@uj .

#### **Problem 5.5: Solve nonlinear equations arising from a vibration ODE**

Consider a nonlinear vibration problem

$$
tau'' + bu'|u'| + s(u) = F(t),\tag{5.84}$$

where m>0 is a constant, <sup>b</sup> <sup>0</sup> is a constant, s.u/ a possibly nonlinear function of u, and F .t/ is a prescribed function. Such models arise from Newton's second law of motion in mechanical vibration problems where s.u/ is a spring or restoring force, mu<sup>00</sup> is mass times acceleration, and bu<sup>0</sup> ju0 j models water or air drag.


Filename: nonlin\_vib.

#### **Exercise 5.6: Find the truncation error of arithmetic mean of products**

In Sect. 5.3.4 we introduce alternative arithmetic means of a product. Say the product is P .t/Q.t/ evaluated at <sup>t</sup> D <sup>t</sup> nC 1 2 . The exact value is

ŒPQ<sup>n</sup>C<sup>1</sup> 2 D <sup>P</sup><sup>n</sup><sup>C</sup> <sup>1</sup> <sup>2</sup> Q<sup>n</sup><sup>C</sup> <sup>1</sup> 2 :

There are two obvious candidates for evaluating ŒPQ<sup>n</sup>C<sup>1</sup> <sup>2</sup> as a mean of values of P and <sup>Q</sup> at tn and tnC<sup>1</sup>. Either we can take the arithmetic mean of each factor <sup>P</sup> and Q,

$$[P\,Q]^{n+\frac{1}{2}} \approx \frac{1}{2}(P^n + P^{n+1})\frac{1}{2}(\mathcal{Q}^n + \mathcal{Q}^{n+1}),\tag{5.85}$$

or we can take the arithmetic mean of the product PQ:

$$[PQ]^{n+\frac{1}{2}} \approx \frac{1}{2} (P^n Q^n + P^{n+1} Q^{n+1}) \,. \tag{5.86}$$

The arithmetic average of P .tnC <sup>1</sup> 2 / is *<sup>O</sup>*.t 2/:

$$P\left(t\_{n+\frac{1}{2}}\right) = \frac{1}{2}(P'' + P^{n+1}) + \mathcal{O}(\Delta t^2) \dots$$

A fundamental question is whether (5.85) and (5.86) have different orders of accuracy in t <sup>D</sup> tnC<sup>1</sup> tn. To investigate this question, expand quantities at tnC<sup>1</sup> and tn in Taylor series around t nC 1 2 , and subtract the true value ŒPQ<sup>n</sup>C<sup>1</sup> <sup>2</sup> from the approximations (5.85) and (5.86) to see what the order of the error terms are.

*Hint* You may explore sympy for carrying out the tedious calculations. A general Taylor series expansion of P .t C <sup>1</sup> <sup>2</sup>t/ around t involving just a general function P .t/ can be created as follows:

```
>>> from sympy import *
>>> t, dt = symbols('t dt')
>>> P = symbols('P', cls=Function)
>>> P(t).series(t, 0, 4)
P(0) + t*Subs(Derivative(P(_x), _x), (_x,), (0,)) +
t**2*Subs(Derivative(P(_x), _x, _x), (_x,), (0,))/2 +
t**3*Subs(Derivative(P(_x), _x, _x, _x), (_x,), (0,))/6 + O(t**4)
>>> P_p = P(t).series(t, 0, 4).subs(t, dt/2)
>>> P_p
P(0) + dt*Subs(Derivative(P(_x), _x), (_x,), (0,))/2 +
dt**2*Subs(Derivative(P(_x), _x, _x), (_x,), (0,))/8 +
dt**3*Subs(Derivative(P(_x), _x, _x, _x), (_x,), (0,))/48 + O(dt**4)
```
The error of the arithmetic mean, <sup>1</sup> <sup>2</sup> .P .<sup>1</sup> <sup>2</sup>t/ C P .<sup>1</sup> <sup>2</sup>t// for <sup>t</sup> D <sup>0</sup> is then

```
>>> P_m = P(t).series(t, 0, 4).subs(t, -dt/2)
>>> mean = Rational(1,2)*(P_m + P_p)
>>> error = simplify(expand(mean) - P(0))
>>> error
dt**2*Subs(Derivative(P(_x), _x, _x), (_x,), (0,))/8 + O(dt**4)
```
Use these examples to investigate the error of (5.85) and (5.86) for <sup>n</sup> D <sup>0</sup>. (Choosing <sup>n</sup> <sup>D</sup> <sup>0</sup> is necessary for not making the expressions too complicated for sympy, but there is of course no lack of generality by using <sup>n</sup> D <sup>0</sup> rather than an arbitrary n - the main point is the product and addition of Taylor series.) Filename: product\_arith\_mean.

#### **Problem 5.7: Newton's method for linear problems**

Suppose we have a linear system F .u/ D Au <sup>b</sup> D <sup>0</sup>. Apply Newton's method to this system, and show that the method converges in one iteration. Filename: Newton\_linear.

#### **Problem 5.8: Discretize a 1D problem with a nonlinear coefficient**

We consider the problem

$$((1+u^2)u')'=1, \quad x \in (0,1), \quad u(0) = u(1) = 0. \tag{5.87}$$

Discretize (5.87) by a centered finite difference method on a uniform mesh. Filename: nonlin\_1D\_coeff\_discretize.

#### **Problem 5.9: Linearize a 1D problem with a nonlinear coefficient**

We have a two-point boundary value problem

$$((1+u^2)u')'=1, \quad x \in (0,1), \quad u(0) = u(1) = 0. \tag{5.88}$$


Filename: nonlin\_1D\_coeff\_linearize.

#### **Problem 5.10: Finite differences for the 1D Bratu problem**

We address the so-called Bratu problem

$$u'' + \lambda e^u = 0, \quad x \in (0, 1), \quad u(0) = u(1) = 0,\tag{5.89}$$

where is a given parameter and u is a function of x. This is a widely used model problem for studying numerical methods for nonlinear differential equations. The problem (5.89) has an exact solution

$$u\_{\mathfrak{C}}(\mathbf{x}) = -2\ln\left(\frac{\cosh((\mathbf{x} - \frac{1}{2})\theta/2)}{\cosh(\theta/4)}\right),$$

where solves

$$
\theta = \sqrt{2\lambda} \cosh(\theta/4) \,\mathrm{J}
$$

There are two solutions of (5.89) for 0<<c and no solution for >c . For <sup>D</sup> c there is one unique solution. The critical value c solves

$$1 = \sqrt{2\lambda\_c} \frac{1}{4} \sinh(\theta(\lambda\_c)/4) \dots$$

A numerical value is c <sup>D</sup> 3:513830719.


Filename: nonlin\_1D\_Bratu\_fd.

#### **Problem 5.11: Discretize a nonlinear 1D heat conduction PDE by finite differences**

We address the 1D heat conduction PDE

$$
\varrho c(T)T\_l = (k(T)T\_x)\_x,
$$

for <sup>x</sup> 2 Œ0; L, where % is the density of the solid material, c.T / is the heat capacity, <sup>T</sup> is the temperature, and k.T / is the heat conduction coefficient. T .x; 0/ D I.x/, and ends are subject to a cooling law:

$$k(T)T\_x|\_{x=0} = h(T)(T - T\_s), \quad -k(T)T\_x|\_{x=L} = h(T)(T - T\_s),$$

where h.T / is a heat transfer coefficient and Ts is the given surrounding temperature.


Filename: nonlin\_1D\_heat\_FD.

#### **Problem 5.12: Differentiate a highly nonlinear term**

The operator r.˛.u/ru/ with ˛.u/ D jruj <sup>q</sup> appears in several physical problems, especially flow of Non-Newtonian fluids. The expression jruj is defined as the Euclidean norm of a vector: jruj 2 D r<sup>u</sup> ru. In a Newton method one has to carry out the differentiation @˛.u/=@cj , for <sup>u</sup> <sup>D</sup> <sup>P</sup> <sup>k</sup> ck k. Show that

$$\frac{\partial}{\partial u\_j} |\nabla u|^q = q |\nabla u|^{q-2} \nabla u \cdot \nabla \psi\_j \ .$$

Filename: nonlin\_differentiate.

#### **Exercise 5.13: Crank-Nicolson for a nonlinear 3D diffusion equation**

Redo Sect. 5.5.1 when a Crank-Nicolson scheme is used to discretize the equations in time and the problem is formulated for three spatial dimensions.

*Hint* Express the Jacobian as Ji;j;k;r;s;t <sup>D</sup> @Fi;j;k=@ur;s;t and observe, as in the 2D case, that Ji;j;k;r;s;t is very sparse: Ji;j;k;r;s;t ¤ <sup>0</sup> only for <sup>r</sup> <sup>D</sup> <sup>i</sup> ˙ <sup>i</sup>, <sup>s</sup> <sup>D</sup> <sup>j</sup> ˙ <sup>1</sup>, and <sup>t</sup> D <sup>k</sup> ˙ <sup>1</sup> as well as <sup>r</sup> D <sup>i</sup>, <sup>s</sup> D <sup>j</sup> , and <sup>t</sup> D <sup>k</sup>. Filename: nonlin\_heat\_FD\_CN\_2D.

#### **Problem 5.14: Find the sparsity of the Jacobian**

Consider a typical nonlinear Laplace term like r ˛.u/r<sup>u</sup> discretized by centered finite differences. Explain why the Jacobian corresponding to this term has the same sparsity pattern as the matrix associated with the corresponding linear term ˛r2u.

*Hint* Set up the unknowns that enter the difference equation at a point .i; j / in 2D or .i; j; k/ in 3D, and identify the nonzero entries of the Jacobian that can arise from such a type of difference equation.

Filename: nonlin\_sparsity\_Jacobian.

#### **Problem 5.15: Investigate a 1D problem with a continuation method**

Flow of a pseudo-plastic power-law fluid between two flat plates can be modeled by

$$\frac{d}{d\boldsymbol{\chi}}\left(\mu\_0 \left|\frac{d\boldsymbol{u}}{d\boldsymbol{\chi}}\right|^{n-1} \frac{d\boldsymbol{u}}{d\boldsymbol{\chi}}\right) = -\beta, \quad \boldsymbol{u}'(0) = 0, \ \boldsymbol{u}(H) = 0, 1$$

where ˇ>0 and <sup>0</sup> > 0 are constants. A target value of <sup>n</sup> may be <sup>n</sup> <sup>D</sup> 0:2.


**Open Access** This chapter is licensed under the terms of the Creative Commons Attribution 4.0 International License (http://creativecommons.org/licenses/by/4.0/), which permits use, sharing, adaptation, distribution and reproduction in any medium or format, as long as you give appropriate credit to the original author(s) and the source, provide a link to the Creative Commons license and indicate if changes were made.

The images or other third party material in this chapter are included in the chapter's Creative Commons license, unless indicated otherwise in a credit line to the material. If material is not included in the chapter's Creative Commons license and your intended use is not permitted by statutory regulation or exceeds the permitted use, you will need to obtain permission directly from the copyright holder.

# **A Useful Formulas**

#### **A.1 Finite Difference Operator Notation**

$$u'(t\_n) \approx \left[D\_l u\right]^n = \frac{u^{n + \frac{1}{2}} - u^{n - \frac{1}{2}}}{\Delta t} \tag{A.1}$$

$$
u'(t\_n) \approx [D\_{2t}u]^n = \frac{u^{n+1} - u^{n-1}}{2\Delta t} \tag{A.2}$$

$$u'(t\_n) = [D\_\,^-u]^n = \frac{u^n - u^{n-1}}{\frac{\Delta t}{\ldots}}\tag{A.3}$$

$$u'(t\_n) \approx [D\_t^+ u]^n = \frac{u^{n+1} - u^n}{\frac{\Delta t}{\dots}} \tag{A.4}$$

$$
\mu'(t\_{n+\theta}) = [\bar{D}\_l u]^{n+\theta} = \frac{u^{n+1} - u^n}{\Delta t} \tag{A.5}
$$

$$[u'(t\_n) \approx [D\_t^{2-} u]^n = \frac{3u^n - 4u^{n-1} + u^{n-2}}{2\Delta t} \tag{A.6}$$

$$
u''(t\_n) \approx [D\_1 D\_1 u]^n = \frac{u^{n+1} - 2u^n + u^{n-1}}{\Delta t^2} \tag{A.7}$$

$$u\left(t\_{n+\frac{1}{2}}\right) \approx [\overline{u}']^{n+\frac{1}{2}} = \frac{1}{2}(u^{n+1} + u^n) \tag{A.8}$$

$$
\mu \left( t\_{n + \frac{1}{2}} \right)^2 \approx [\overline{u^2}^{l, \mathfrak{g}}]^{n + \frac{1}{2}} = u^{n+1} u^n \tag{A.9}
$$

$$
\mu\left(t\_{n+\frac{1}{2}}\right) \approx [\overline{u}^{t,h}]^{n+\frac{1}{2}} = \frac{2}{\frac{1}{u^{n+1}} + \frac{1}{u^n}}\tag{A.10}
$$

$$
\mu(t\_{n+\theta}) \approx [\overline{u}^{\prime,\theta}]^{n+\theta} = \theta u^{n+1} + (1-\theta)u^n,\tag{A.11}
$$

$$t\_{n+\theta} = \theta t\_{n+1} + (1-\theta)t\_{n-1} \tag{A.12}$$

Some may wonder why is absent on the right-hand side of (A.5). The fraction is an approximation to the derivative at the point tnC- <sup>D</sup> tnC<sup>1</sup> <sup>C</sup> .1 -/tn.

#### **A.2 Truncation Errors of Finite Difference Approximations**

$$u'\_{\mathbf{c}}(t\_n) = [D\_t u\_{\mathbf{c}}]^n + R^n = \frac{u\_{\mathbf{c}}^{n + \frac{1}{2}} - u\_{\mathbf{c}}^{n - \frac{1}{2}}}{\Delta t} + R^n,$$

$$R^n = -\frac{1}{24} u\_{\mathbf{c}}^{\prime\prime\prime}(t\_n) \Delta t^2 + \mathcal{O}(\Delta t^4) \tag{A.13}$$

$$u^{n+1} - u^{n-1}$$

$$u\_{\mathbf{c}}'(t\_n) = [D\_{2l}u\_{\mathbf{c}}]^n + R^n = \frac{u\_{\mathbf{c}}^{n+1} - u\_{\mathbf{c}}^{n-1}}{2\Delta t} + R^n,$$

$$R^n = -\frac{1}{6}u\_{\mathbf{c}}^{\prime\prime\prime}(t\_n)\Delta t^2 + \mathcal{O}(\Delta t^4) \tag{A.14}$$

$$u\_{\mathbf{c}}'(t\_n) = [D\_{\!\!\! }^- u\_{\mathbf{c}}]^n + R^n = \frac{u\_{\mathbf{c}}^n - u\_{\mathbf{c}}^{n-1}}{\Delta t} + R^n,$$

$$R^n = -\frac{1}{2} u\_{\mathbf{c}}^{\prime\prime}(t\_n)\Delta t + \mathcal{O}(\Delta t^2) \tag{A.15}$$

$$u\_{\mathbf{c}}'(t\_n) = [D\_l^+ u\_{\mathbf{c}}]^n + R^n = \frac{u\_{\mathbf{c}}^{n+1} - u\_{\mathbf{c}}^n}{\Delta t} + R^n,$$

$$R^n = \frac{1}{2} u\_{\mathbf{c}}'(t\_n) \Delta t + \mathcal{O}(\Delta t^2) \tag{A.16}$$

$$\begin{split} u\_{\mathbf{c}}'(t\_{n+\theta}) &= [\bar{D}\_{l}u\_{\mathbf{c}}]^{n+\theta} + R^{n+\theta} = \frac{u\_{\mathbf{c}}^{n+1} - u\_{\mathbf{c}}^{n}}{\Delta t} + R^{n+\theta}, \\ R^{n+\theta} &= -\frac{1}{2}(1 - 2\theta)u\_{\mathbf{c}}^{\prime\prime}(t\_{n+\theta})\Delta t + \frac{1}{6}((1 - \theta)^{3} - \theta^{3})u\_{\mathbf{c}}^{\prime\prime\prime}(t\_{n+\theta})\Delta t^{2} \\ &+ \mathcal{O}(\Delta t^{3}) \\ &\quad \dots \quad \dots \quad \dots \quad \dots \end{split} \tag{A.17}$$

$$u\_{\mathbf{c}}'(t\_n) = [D\_t^{2-} u\_{\mathbf{c}}]^n + R^n = \frac{3u\_{\mathbf{c}}^n - 4u\_{\mathbf{c}}^{n-1} + u\_{\mathbf{c}}^{n-2}}{2\Delta t} + R^n,$$

$$R^n = \frac{1}{3}u\_{\mathbf{c}}'''(t\_n)\Delta t^2 + \mathcal{O}(\Delta t^3) \tag{A.18}$$

$$u\_{\mathfrak{c}}^{\prime\prime}(t\_n) = \left[D\_1 D\_1 u\_{\mathfrak{c}}\right]^n + R^n = \frac{u\_{\mathfrak{c}}^{n+1} - 2u\_{\mathfrak{c}}^n + u\_{\mathfrak{c}}^{n-1}}{\Delta t^2} + R^n,$$

$$R^n = -\frac{1}{12} u\_{\mathfrak{c}}^{\prime\prime\prime\prime}(t\_n) \Delta t^2 + \mathcal{O}(\Delta t^4) \tag{A.19}$$

$$u\_{\mathfrak{c}\to\pm} = \underbrace{\alpha u\_{\mathfrak{c}\to\pm} \cdot \alpha^{n+\theta} \cdot \alpha^{n+\theta} \cdot \alpha^{n+1} \cdot \alpha^{n+1} \cdot \alpha^{n+\theta} \cdot \alpha^{n+\theta}}\_{\text{(A.10)}} \tag{A.10}$$

$$u\_{\mathfrak{C}}(t\_{n+\theta}) = [\overline{u\_{\mathfrak{C}}}^{\iota,\theta}]^{n+\theta} + R^{n+\theta} = \theta u\_{\mathfrak{C}}^{n+1} + (1-\theta)u\_{\mathfrak{C}}^{n} + R^{n+\theta},$$

$$R^{n+\theta} = -\frac{1}{2}u\_{\mathfrak{C}}^{\iota}(t\_{n+\theta})\Delta t^{2}\theta(1-\theta) + \mathcal{O}(\Delta t^{3}).\tag{A.20}$$

#### **A.3 Finite Differences of Exponential Functions**

**Complex exponentials** Let <sup>u</sup><sup>n</sup> D exp .i!nt/ D <sup>e</sup>i!tn .

$$[D\_l D\_l u]^n = u^n \frac{2}{\Delta t} (\cos \omega t - 1) = -\frac{4}{\Delta t} \sin^2 \left(\frac{\omega \Delta t}{2}\right),\tag{A.21}$$

$$\mathbb{E}\left[D\_t^+u\right]^n = u^n \frac{1}{\Delta t} (\exp\left(i\omega \Delta t\right) - 1),\tag{A.22}$$

$$[D\_l^-u]^n = u^n \frac{1}{\Delta t} (1 - \exp\left(-i\omega \Delta t\right)),\tag{A.23}$$

$$[D\_l u]^n = u^n \frac{2}{\Delta t} i \sin \left(\frac{\omega \Delta t}{2}\right),\tag{A.24}$$

$$\left[D\_{2t}u\right]^n = u^n \frac{1}{\Delta t} i \sin\left(\omega \Delta t\right). \tag{A.25}$$

**Real exponentials** Let <sup>u</sup><sup>n</sup> <sup>D</sup> exp .!nt/ <sup>D</sup> <sup>e</sup>!tn .

$$[D\_l D\_l u]'' = u'' \frac{2}{\Delta t} (\cos \omega t - 1) = -\frac{4}{\Delta t} \sin^2 \left(\frac{\omega \Delta t}{2}\right),\tag{A.26}$$

$$\mathbb{E}\left[D\_t^+u\right]^n = u^n \frac{1}{\Delta t} (\exp\left(i\omega \Delta t\right) - 1),\tag{A.27}$$

$$[D\_l^-u]^n = u^n \frac{1}{\Delta t} (1 - \exp\left(-i\omega \Delta t\right)),\tag{A.28}$$

$$[D\_l u]'' = u'' \frac{2}{\Delta t} i \sin \left(\frac{\omega \Delta t}{2}\right),\tag{A.29}$$

$$\left[D\_{2l}u\right]^n = u^n \frac{1}{\Delta t} i \sin\left(\omega \Delta t\right). \tag{A.30}$$

# **A.4 Finite Differences of** t<sup>n</sup>

The following results are useful when checking if a polynomial term in a solution fulfills the discrete equation for the numerical method.

$$[D\_l^+t]^n = 1,\tag{A.31}$$

$$[D\_l^-t]^n = 1,\tag{A.32}$$

$$[D\_l t]^n = 1,\tag{A.33}$$

$$[D\_l \quad \text{A}^n \quad \dots]$$

$$\begin{aligned} [D\_{2l}t]^n &= 1, & \quad \quad \quad \quad \quad \quad (\text{A.34})\\ [D\_l D\_l t]^n &= 0 \, \quad \quad \quad \quad \quad \quad \quad \quad \quad \quad \quad \quad \quad (\text{A.35}) \end{aligned}$$

The next formulas concern the action of difference operators on a t <sup>2</sup> term.

$$[D\_t^+t^2]^n = (2n+1)\Delta t,\tag{A.36}$$

$$[D\_t^-t^2]^n = (2n-1)\Delta t,\tag{A.37}$$

$$[D\_l t^2]^n = 2n \Delta t,\tag{A.38}$$
 
$$\begin{array}{ll} \dots & \dots & \\ \dots & \dots & \\ \dots & \dots & \end{array}$$

$$[D\_{2l}t^2]^n = 2n\Delta t,\tag{A.39}$$

$$[D\_l D\_l t^2]^n = 2\,. \tag{A.40}$$

Finally, we present formulas for a t <sup>3</sup> term:

$$[D\_{\mu}^{+}t^{3}]^{n} = 3(n\Delta t)^{2} + 3n\Delta t^{2} + \Delta t^{2},\tag{A.41}$$

$$[D\_t^-t^3]^n = 3(n\Delta t)^2 - 3n\Delta t^2 + \Delta t^2,\tag{A.42}$$

$$[D\_t t^3]^n = \Im(n\Delta t)^2 + \frac{1}{4}\Delta t^2,\tag{A.43}$$

$$[D\_{2l}t^3]^n = \mathfrak{Z}(n\Delta t)^2 + \Delta t^2,\tag{A.44}$$

ŒDtDt t 3 n D 6nt : (A.45)

#### **A.4.1 Software**

Application of finite difference operators to polynomials and exponential functions, resulting in the formulas above, can easily be computed by some sympy code (from the file lib.py):

```
from sympy import *
t, dt, n, w = symbols('t dt n w', real=True)
# Finite difference operators
def D_t_forward(u):
    return (u(t + dt) - u(t))/dt
def D_t_backward(u):
    return (u(t) - u(t-dt))/dt
def D_t_centered(u):
    return (u(t + dt/2) - u(t-dt/2))/dt
def D_2t_centered(u):
    return (u(t + dt) - u(t-dt))/(2*dt)
def D_t_D_t(u):
    return (u(t + dt) - 2*u(t) + u(t-dt))/(dt**2)
op_list = [D_t_forward, D_t_backward,
           D_t_centered, D_2t_centered, D_t_D_t]
```

```
def ft1(t):
    return t
def ft2(t):
    return t**2
def ft3(t):
    return t**3
def f_expiwt(t):
    return exp(I*w*t)
def f_expwt(t):
    return exp(w*t)
func_list = [ft1, ft2, ft3, f_expiwt, f_expwt]
```
To see the results, one can now make a simple loop over the different types of functions and the various operators associated with them:

```
for func in func_list:
    for op in op_list:
        f = func
        e = op(f)
        e = simplify(expand(e))
        print e
        if func in [f_expiwt, f_expwt]:
            e = e/f(t)
        e = e.subs(t, n*dt)
        print expand(e)
        print factor(simplify(expand(e)))
```
# **B Truncation Error Analysis**

Truncation error analysis provides a widely applicable framework for analyzing the accuracy of finite difference schemes. This type of analysis can also be used for finite element and finite volume methods if the discrete equations are written in finite difference form. The result of the analysis is an asymptotic estimate of the error in the scheme on the form C hr , where h is a discretization parameter (t, x, etc.), r is a number, known as the convergence rate, and C is a constant, typically dependent on the derivatives of the exact solution.

Knowing r gives understanding of the accuracy of the scheme. But maybe even more important, a powerful verification method for computer codes is to check that the empirically observed convergence rates in experiments coincide with the theoretical value of r found from truncation error analysis.

The analysis can be carried out by hand, by symbolic software, and also numerically. All three methods will be illustrated. From examining the symbolic expressions of the truncation error we can add correction terms to the differential equations in order to increase the numerical accuracy.

In general, the term truncation error refers to the discrepancy that arises from performing a finite number of steps to approximate a process with infinitely many steps. The term is used in a number of contexts, including truncation of infinite series, finite precision arithmetic, finite differences, and differential equations. We shall be concerned with computing truncation errors arising in finite difference formulas and in finite difference discretizations of differential equations.

#### **B.1 Overview of Truncation Error Analysis**

#### **B.1.1 Abstract Problem Setting**

Consider an abstract differential equation

$$
\mathcal{L}(u) = 0,
$$

where *L*.u/ is some formula involving the unknown u and its derivatives. One example is *<sup>L</sup>*.u/ D <sup>u</sup><sup>0</sup> .t/Ca.t/u.t/b.t/, where <sup>a</sup> and <sup>b</sup> are constants or functions of time. We can discretize the differential equation and obtain a corresponding discrete model, here written as

$$
\mathcal{L}\_\Delta(u) = 0 \,.
$$

The solution u of this equation is the *numerical solution*. To distinguish the numerical solution from the exact solution of the differential equation problem, we denote the latter by ue and write the differential equation and its discrete counterpart as

$$\begin{aligned} \mathcal{L}(u\_\mathfrak{e}) &= 0, \\ \mathcal{L}\_\Delta(u) &= 0. \end{aligned}$$

Initial and/or boundary conditions can usually be left out of the truncation error analysis and are omitted in the following.

The numerical solution u is, in a finite difference method, computed at a collection of mesh points. The discrete equations represented by the abstract equation *<sup>L</sup>*.u/ D <sup>0</sup> are usually algebraic equations involving <sup>u</sup> at some neighboring mesh points.

#### **B.1.2 Error Measures**

A key issue is how accurate the numerical solution is. The ultimate way of addressing this issue would be to compute the error <sup>u</sup><sup>e</sup> <sup>u</sup> at the mesh points. This is usually extremely demanding. In very simplified problem settings we may, however, manage to derive formulas for the numerical solution u, and therefore closed form expressions for the error <sup>u</sup><sup>e</sup> <sup>u</sup>. Such special cases can provide considerable insight regarding accuracy and stability, but the results are established for special problems.

The error <sup>u</sup><sup>e</sup> <sup>u</sup> can be computed empirically in special cases where we know ue. Such cases can be constructed by the method of manufactured solutions, where we choose some exact solution <sup>u</sup><sup>e</sup> <sup>D</sup> <sup>v</sup> and fit a source term <sup>f</sup> in the governing differential equation *<sup>L</sup>*.ue/ <sup>D</sup> <sup>f</sup> such that <sup>u</sup><sup>e</sup> <sup>D</sup> <sup>v</sup> is a solution (i.e., <sup>f</sup> <sup>D</sup> *<sup>L</sup>*.v/). Assuming an error model of the form C hr , where h is the discretization parameter, such as t or x, one can estimate the convergence rate r. This is a widely applicable procedure, but the validity of the results is, strictly speaking, tied to the chosen test problems.

Another error measure arises by asking to what extent the exact solution ue fits the discrete equations. Clearly, <sup>u</sup><sup>e</sup> is in general not a solution of *<sup>L</sup>*.u/ <sup>D</sup> <sup>0</sup>, but we can define the residual

$$
\mathcal{R} = \mathcal{L}\_{\Delta}(u\_{\mathbf{e}}),
$$

and investigate how close R is to zero. A small R means intuitively that the discrete equations are close to the differential equation, and then we are tempted to think that u<sup>n</sup> must also be close to ue.tn/.

The residual R is known as the truncation error of the finite difference scheme *<sup>L</sup>*.u/ D <sup>0</sup>. It appears that the truncation error is relatively straightforward to compute by hand or symbolic software *without specializing the differential equation and the discrete model to a special case*. The resulting R is found as a power series in the discretization parameters. The leading-order terms in the series provide an asymptotic measure of the accuracy of the numerical solution method (as the discretization parameters tend to zero). An advantage of truncation error analysis, compared to empirical estimation of convergence rates, or detailed analysis of a special problem with a mathematical expression for the numerical solution, is that the truncation error analysis reveals the accuracy of the various building blocks in the numerical method and how each building block impacts the overall accuracy. The analysis can therefore be used to detect building blocks with lower accuracy than the others.

Knowing the truncation error or other error measures is important for verification of programs by empirically establishing convergence rates. The forthcoming text will provide many examples on how to compute truncation errors for finite difference discretizations of ODEs and PDEs.

#### **B.2 Truncation Errors in Finite Difference Formulas**

The accuracy of a finite difference formula is a fundamental issue when discretizing differential equations. We shall first go through a particular example in detail and thereafter list the truncation error in the most common finite difference approximation formulas.

#### **B.2.1 Example: The Backward Difference for** u<sup>0</sup> .t/

Consider a backward finite difference approximation of the first-order derivative u<sup>0</sup> :

$$[D\_{\iota}^{-}u]^{n} = \frac{u^{n} - u^{n-1}}{\Delta t} \approx u^{\prime}(t\_{n})\,. \tag{B.1}$$

Here, u<sup>n</sup> means the value of some function u.t/ at a point tn, and ŒD <sup>t</sup> u<sup>n</sup> is the *discrete derivative* of u.t/ at <sup>t</sup> D tn. The discrete derivative computed by a finite difference is, in general, not exactly equal to the derivative u<sup>0</sup> .tn/. The error in the approximation is

$$R^n = [D\_t^- u]^n - u'(t\_n) \,. \tag{B.2}$$

The common way of calculating R<sup>n</sup> is to


The result is an expression for R<sup>n</sup> in terms of a power series in t. The error R<sup>n</sup> is commonly referred to as the *truncation error* of the finite difference formula.

The Taylor series formula often found in calculus books takes the form

$$f(x+h) = \sum\_{i=0}^{\infty} \frac{1}{i!} \frac{d^i f}{dx^i}(x) h^i \dots$$

In our application, we expand the Taylor series around the point where the finite difference formula approximates the derivative. The Taylor series of u<sup>n</sup> at tn is simply u.tn/, while the Taylor series of u<sup>n</sup><sup>1</sup> at tn must employ the general formula,

$$\begin{aligned} u(t\_{n-1}) &= u(t - \Delta t) = \sum\_{i=0}^{\infty} \frac{1}{i!} \frac{d^i u}{dt^i}(t\_n)(-\Delta t)^i \\ &= u(t\_n) - u'(t\_n)\Delta t + \frac{1}{2}u''(t\_n)\Delta t^2 + \mathcal{O}(\Delta t^3), \end{aligned}$$

where *<sup>O</sup>*.t 3/ means a power-series in t where the lowest power is t <sup>3</sup>. We assume that t is small such that t<sup>p</sup> t <sup>q</sup> if <sup>p</sup> is smaller than <sup>q</sup>. The details of higher-order terms in t are therefore not of much interest. Inserting the Taylor series above in the right-hand side of (B.2) gives rise to some algebra:

$$\begin{aligned} [[D\_t^- u]^n - u'(t\_n) &= \frac{u(t\_n) - u(t\_{n-1})}{\Delta t} - u'(t\_n) \\ &= \frac{u(t\_n) - (u(t\_n) - u'(t\_n)\Delta t + \frac{1}{2}u''(t\_n)\Delta t^2 + \mathcal{O}(\Delta t^3))}{\Delta t} - u'(t\_n) \\ &= -\frac{1}{2}u''(t\_n)\Delta t + \mathcal{O}(\Delta t^2), \end{aligned}$$

which is, according to (B.2), the truncation error:

$$R^n = -\frac{1}{2}u''(t\_n)\Delta t + \mathcal{O}(\Delta t^2) \,. \tag{B.3}$$

The dominating term for small t is <sup>1</sup> <sup>2</sup>u00.tn/t, which is proportional to t, and we say that the truncation error is of *first order* in t.

#### **B.2.2 Example: The Forward Difference for** u<sup>0</sup> .t/

We can analyze the approximation error in the forward difference

$$
u'(t\_n) \approx [D\_t^+ u]^n = \frac{u^{n+1} - u^n}{\Delta t},$$

by writing

$$\mathcal{R}'' = [D\_{\iota}^{+}u]^{n} - u'(t\_n),$$

and expanding u<sup>n</sup>C<sup>1</sup> in a Taylor series around tn,

$$u(t\_{n+1}) = u(t\_n) + u'(t\_n)\Delta t + \frac{1}{2}u''(t\_n)\Delta t^2 + \mathcal{O}(\Delta t^3)\,.$$

The result becomes

$$R = \frac{1}{2}u''(t\_n)\Delta t + \mathcal{O}(\Delta t^2),$$

showing that also the forward difference is of first order.

#### **B.2.3 Example: The Central Difference for** u<sup>0</sup> .t/

For the central difference approximation,

$$u'(t\_n) \approx [D\_l u]^n, \quad [D\_l u]^n = \frac{u^{n + \frac{1}{2}} - u^{n - \frac{1}{2}}}{\Delta t},$$

we write

$$\boldsymbol{R}^n = [D\_t \boldsymbol{u}]^n - \boldsymbol{u}'(t\_n),$$

and expand u.tnC <sup>1</sup> 2 / and u.tn <sup>1</sup> 2 / in Taylor series around the point tn where the derivative is evaluated. We have

$$\begin{split} u\left(t\_{n+\frac{1}{2}}\right) &= u(t\_n) + u'(t\_n)\frac{1}{2}\Delta t + \frac{1}{2}u''(t\_n)\left(\frac{1}{2}\Delta t\right)^2 + \\ &\quad \frac{1}{6}u'''(t\_n)\left(\frac{1}{2}\Delta t\right)^3 + \frac{1}{24}u'''(t\_n)\left(\frac{1}{2}\Delta t\right)^4 + \\ &\quad \frac{1}{120}u'''(t\_n)\left(\frac{1}{2}\Delta t\right)^5 + \mathcal{O}(\Delta t^6), \\ u\left(t\_{n-\frac{1}{2}}\right) &= u(t\_n) - u'(t\_n)\frac{1}{2}\Delta t + \frac{1}{2}u''(t\_n)\left(\frac{1}{2}\Delta t\right)^2 - \\ &\quad \frac{1}{6}u'''(t\_n)\left(\frac{1}{2}\Delta t\right)^3 + \frac{1}{24}u'''(t\_n)\left(\frac{1}{2}\Delta t\right)^4 - \\ &\quad \frac{1}{120}u^{\prime\prime\prime\prime}(t\_n)\left(\frac{1}{2}\Delta t\right)^5 + \mathcal{O}(\Delta t^6). \end{split}$$

Now,

$$
\mu \left( t\_{n + \frac{1}{2}} \right) - \mu \left( t\_{n - \frac{1}{2}} \right) = \mu'(t\_n) \Delta t + \frac{1}{24} \mu'''(t\_n) \Delta t^3 + \frac{1}{960} \mu^{\prime\prime\prime\prime}(t\_n) \Delta t^5 + \mathcal{O}(\Delta t^7) \, .
$$

By collecting terms in ŒDtu<sup>n</sup> <sup>u</sup><sup>0</sup> .tn/ we find the truncation error to be

$$R^n = \frac{1}{24} \iota^{\prime\prime\prime}(t\_n) \Delta t^2 + \mathcal{O}(\Delta t^4),\tag{B.4}$$

with only even powers of t. Since <sup>R</sup> t <sup>2</sup> we say the centered difference is of *second order* in t.

#### **B.2.4 Overview of Leading-Order Error Terms in Finite Difference Formulas**

Here we list the leading-order terms of the truncation errors associated with several common finite difference formulas for the first and second derivatives.

$$[D\_l u]^n = \frac{u^{n + \frac{1}{2}} - u^{n - \frac{1}{2}}}{\Delta t} = u'(t\_n) + R^n,\tag{B.5}$$

$$R'' = \frac{1}{24} \mu''''(t\_n) \Delta t^2 + \mathcal{O}(\Delta t^4) \tag{B.6}$$

$$\left[D\_{2l}u\right]'' = \frac{u^{n+1} - u^{n-1}}{2\Delta t} = u'(t\_n) + R'',\tag{B.7}$$

$$R^n = \frac{1}{6} \mu^{\prime\prime\prime}(t\_n) \Delta t^2 + \mathcal{O}(\Delta t^4) \tag{B.8}$$

$$[D\_{\iota}^{-}u]^{n} = \frac{u^{n} - u^{n-1}}{\Delta t} = u^{\iota}(t\_{n}) + R^{n},\tag{B.9}$$

$$R^n = -\frac{1}{2}u''(t\_n)\Delta t + \mathcal{O}(\Delta t^2) \tag{B.10}$$

$$[D\_t^+u]^n = \frac{u^{n+1} - u^n}{\Delta t} = u'(t\_n) + R^n,\tag{B.11}$$

$$R^n = \frac{1}{2}u''(t\_n)\Delta t + \mathcal{O}(\Delta t^2) \tag{B.12}$$

$$[\bar{D}\_l u]^{n+\theta} = \frac{u^{n+1} - u^n}{\Delta t} = u'(t\_{n+\theta}) + R^{n+\theta},\tag{B.13}$$

$$\mathcal{R}^{n+\theta} = \frac{1}{2}(1 - 2\theta)u^{\prime\prime}(t\_{n+\theta})\Delta t - \frac{1}{6}((1-\theta)^3 - \theta^3)u^{\prime\prime\prime}(t\_{n+\theta})\Delta t^2 + \mathcal{O}(\Delta t^3) \tag{B.14}$$

$$[D\_t^{2-}u]^n = \frac{\Im u^n - 4u^{n-1} + u^{n-2}}{2\Delta t} = u'(t\_n) + R^n,\tag{B.15}$$

$$R^n = -\frac{1}{3}u^{\prime\prime\prime}(t\_n)\Delta t^2 + \mathcal{O}(\Delta t^3) \tag{B.16}$$

$$[D\_l D\_l u]^n = \frac{u^{n+1} - 2u^n + u^{n-1}}{\Delta t^2} = u''(t\_n) + R^n,\tag{B.17}$$

$$R^n = \frac{1}{12} \mu^{\prime\prime\prime}(t\_n) \Delta t^2 + \mathcal{O}(\Delta t^4) \tag{B.18}$$

It will also be convenient to have the truncation errors for various means or averages. The weighted arithmetic mean leads to

$$[\overline{u}^{\iota,\theta}]^{n+\theta} = \theta u^{n+1} + (1-\theta)u^n = u(t\_{n+\theta}) + R^{n+\theta},\tag{B.19}$$

$$\mathcal{R}^{n+\theta} = \frac{1}{2} \mathfrak{u}''(t\_{n+\theta}) \Delta t^2 \theta (1-\theta) + \mathcal{O}(\Delta t^3) \,. \tag{B.20}$$

The standard arithmetic mean follows from this formula when - D <sup>1</sup> <sup>2</sup> . Expressed at point tn we get

$$[\overline{u}']'' = \frac{1}{2} \left( u^{n - \frac{1}{2}} + u^{n + \frac{1}{2}} \right) = u(t\_n) + R'',\tag{B.21}$$

$$R'' = \frac{1}{8} \boldsymbol{u}''(t\_n) \Delta t^2 + \frac{1}{384} \boldsymbol{u}'''(t\_n) \Delta t^4 + \mathcal{O}(\Delta t^6) \,. \tag{B.22}$$

The geometric mean also has an error *<sup>O</sup>*.t 2/:

$$\left[\overline{u}^{\overline{\ast}^{t,\mathfrak{g}}}\right]^n = u^{n-\frac{1}{2}}u^{n+\frac{1}{2}} = (u^n)^2 + R^n,\tag{B.23}$$

$$R^n = -\frac{1}{4}u'(t\_n)^2\Delta t^2 + \frac{1}{4}u(t\_n)u''(t\_n)\Delta t^2 + \mathcal{O}(\Delta t^4). \tag{B.24}$$

The harmonic mean is also second-order accurate:

$$\left[\overline{u}^{\prime,h}\right]^n = u^n = \frac{2}{\frac{1}{u^{n-\frac{1}{2}}} + \frac{1}{u^{n+\frac{1}{2}}}} + R^{n+\frac{1}{2}},\tag{B.25}$$

$$R^n = -\frac{\mu'(t\_n)^2}{4u(t\_n)}\Delta t^2 + \frac{1}{8}\mu''(t\_n)\Delta t^2. \tag{B.26}$$

#### **B.2.5 Software for Computing Truncation Errors**

We can use sympy to aid calculations with Taylor series. The derivatives can be defined as symbols, say D3f for the 3rd derivative of some function f . A truncated Taylor series can then be written as f + D1f\*h + D2f\*h\*\*2/2. The following class takes some symbol f for the function in question and makes a list of symbols for the derivatives. The \_\_call\_\_ method computes the symbolic form of the series truncated at num\_terms terms.

```
import sympy as sym
class TaylorSeries:
    """Class for symbolic Taylor series."""
    def __init__(self, f, num_terms=4):
        self.f = f
        self.N = num_terms
        # Introduce symbols for the derivatives
        self.df = [f]
        for i in range(1, self.N+1):
            self.df.append(sym.Symbol('D%d%s' % (i, f.name)))
    def __call__(self, h):
        """Return the truncated Taylor series at x+h."""
        terms = self.f
        for i in range(1, self.N+1):
            terms += sym.Rational(1, sym.factorial(i))*self.df[i]*h**i
        return terms
```
We may, for example, use this class to compute the truncation error of the Forward Euler finite difference formula:

```
>>> from truncation_errors import TaylorSeries
>>> from sympy import *
>>> u, dt = symbols('u dt')
>>> u_Taylor = TaylorSeries(u, 4)
>>> u_Taylor(dt)
D1u*dt + D2u*dt**2/2 + D3u*dt**3/6 + D4u*dt**4/24 + u
>>> FE = (u_Taylor(dt) - u)/dt
>>> FE
(D1u*dt + D2u*dt**2/2 + D3u*dt**3/6 + D4u*dt**4/24)/dt
>>> simplify(FE)
D1u + D2u*dt/2 + D3u*dt**2/6 + D4u*dt**3/24
```
The truncation error consists of the terms after the first one (u0 ).

The module file trunc/truncation\_errors.py contains another class DiffOp with symbolic expressions for most of the truncation errors listed in the previous section. For example:

```
>>> from truncation_errors import DiffOp
>>> from sympy import *
>>> u = Symbol('u')
>>> diffop = DiffOp(u, independent_variable='t')
>>> diffop['geometric_mean']
>>> diffop['Dtm']
D1u + D2u*dt/2 + D3u*dt**2/6 + D4u*dt**3/24
>>> >>> diffop.operator_names()
['geometric_mean', 'harmonic_mean', 'Dtm', 'D2t', 'DtDt',
 'weighted_arithmetic_mean', 'Dtp', 'Dt']
```
The indexing of diffop applies names that correspond to the operators: Dtp for DC <sup>t</sup> , Dtm for D <sup>t</sup> , Dt for Dt , D2t for D2t , DtDt for DtDt .

#### **B.3 Exponential Decay ODEs**

We shall now compute the truncation error of a finite difference scheme for a differential equation. Our first problem involves the following linear ODE that models exponential decay,

$$u'(t) = -au(t)\,. \tag{B.27}$$

#### **B.3.1 Forward Euler Scheme**

We begin with the Forward Euler scheme for discretizing (B.27):

$$[D\_l^+u = -au]^n. \tag{B.28}$$

The idea behind the truncation error computation is to insert the exact solution ue of the differential equation problem (B.27) in the discrete equations (B.28) and find the residual that arises because ue does not solve the discrete equations. Instead, ue solves the discrete equations with a residual R<sup>n</sup>:

$$[D\_l^+u\_\mathfrak{e} + au\_\mathfrak{e} = R]^n. \tag{\text{B.29}}$$

From (B.11)–(B.12) it follows that

$$[D\_t^+ u\_\mathfrak{c}]'' = u\_\mathfrak{c}'(t\_n) + \frac{1}{2} u\_\mathfrak{c}''(t\_n) \Delta t + \mathcal{O}(\Delta t^2),$$

which inserted in (B.29) results in

$$u'\_{\mathfrak{c}}(t\_n) + \frac{1}{2}u''\_{\mathfrak{c}}(t\_n)\Delta t + \mathcal{O}(\Delta t^2) + au\_{\mathfrak{c}}(t\_n) = R^n.$$

Now, u<sup>0</sup> <sup>e</sup>.tn/ C au<sup>n</sup> <sup>e</sup> <sup>D</sup> <sup>0</sup> since <sup>u</sup><sup>e</sup> solves the differential equation. The remaining terms constitute the residual:

$$R^{\boldsymbol{\pi}} = \frac{1}{2} \boldsymbol{u}\_{\mathbf{c}}^{\prime\prime}(t\_{\boldsymbol{n}}) \Delta t + \mathcal{O}(\Delta t^2) \,. \tag{B.30}$$

This is the truncation error R<sup>n</sup> of the Forward Euler scheme.

Because R<sup>n</sup> is proportional to t, we say that the Forward Euler scheme is of first order in t. However, the truncation error is just one error measure, and it is not equal to the true error u<sup>n</sup> <sup>e</sup> <sup>u</sup><sup>n</sup>. For this simple model problem we can compute a range of different error measures for the Forward Euler scheme, including the true error u<sup>n</sup> <sup>e</sup> <sup>u</sup><sup>n</sup>, and all of them have dominating terms proportional to t.

#### **B.3.2 Crank-Nicolson Scheme**

For the Crank-Nicolson scheme,

$$\left[D\_{l}u = -au\right]^{n + \frac{1}{2}},\tag{B.31}$$

we compute the truncation error by inserting the exact solution of the ODE and adding a residual R,

$$[D\_l u\_\mathbf{c} + a \overline{u\_\mathbf{c}}' = R]^{n + \frac{1}{2}}.\tag{\text{B.32}}$$

The term ŒDtue nC 1 <sup>2</sup> is easily computed from (B.5)–(B.6) by replacing <sup>n</sup> with <sup>n</sup>C <sup>1</sup> 2 in the formula,

$$\left[D\_{\boldsymbol{t}}u\_{\mathbf{c}}\right]^{n+\frac{1}{2}} = u'\_{\mathbf{c}}\left(t\_{n+\frac{1}{2}}\right) + \frac{1}{24}u\_{\mathbf{c}}^{\prime\prime\prime}\left(t\_{n+\frac{1}{2}}\right)\Delta t^{2} + \mathcal{O}(\Delta t^{4})\dots$$

The arithmetic mean is related to u.tnC <sup>1</sup> 2 / by (B.21)–(B.22) so

$$[a\overline{u\_{\mathfrak{c}}}']^{n+\frac{1}{2}} = u\_{\mathfrak{c}}\left(t\_{n+\frac{1}{2}}\right) + \frac{1}{8}u\_{\mathfrak{c}}''(t\_n)\Delta t^2 + \mathcal{O}(\Delta t^4) \dots$$

Inserting these expressions in (B.32) and observing that u<sup>0</sup> <sup>e</sup>.tnC <sup>1</sup> 2 / C au<sup>n</sup><sup>C</sup> <sup>1</sup> 2 <sup>e</sup> <sup>D</sup> <sup>0</sup>, because ue.t/ solves the ODE u<sup>0</sup> .t/ D au.t/ at any point <sup>t</sup>, we find that

$$R^{n+\frac{1}{2}} = \left(\frac{1}{24}u\_{\mathbf{c}}^{\prime\prime\prime}\left(t\_{n+\frac{1}{2}}\right) + \frac{1}{8}u\_{\mathbf{c}}^{\prime\prime}(t\_n)\right)\Delta t^2 + \mathcal{O}(\Delta t^4) \,. \tag{B.33}$$

Here, the truncation error is of second order because the leading term in R is proportional to t <sup>2</sup>.

At this point it is wise to redo some of the computations above to establish the truncation error of the Backward Euler scheme, see Exercise B.4.

# **B.3.3 The -Rule**

We may also compute the truncation error of the --rule,

$$[\bar{D}\_t u = -a \overline{u}^{t, \theta}]^{n+\theta} \dots$$

Our computational task is to find R<sup>n</sup>Cin

$$[\bar{D}\_t u\_\mathbf{c} + a \overline{u\_\mathbf{c}}^{,\theta} = R]^{n+\theta} \, . $$

From (B.13)–(B.14) and (B.19)–(B.20) we get expressions for the terms with ue. Using that u<sup>0</sup> <sup>e</sup>.tnC- / <sup>C</sup> aue.tnC-/ <sup>D</sup> <sup>0</sup>, we end up with

$$\begin{split} R^{\boldsymbol{\alpha}+\boldsymbol{\theta}} &= \left(\frac{1}{2} - \boldsymbol{\theta}\right) u\_{\mathsf{c}}^{\prime\prime}(t\_{\mathsf{n}+\boldsymbol{\theta}}) \Delta t + \frac{1}{2} \boldsymbol{\theta} (1 - \boldsymbol{\theta}) u\_{\mathsf{c}}^{\prime\prime}(t\_{\mathsf{n}+\boldsymbol{\theta}}) \Delta t^{2} \\ &+ \frac{1}{2} (\boldsymbol{\theta}^{2} - \boldsymbol{\theta} + \boldsymbol{3}) u\_{\mathsf{c}}^{\prime\prime\prime}(t\_{\mathsf{n}+\boldsymbol{\theta}}) \Delta t^{2} + \mathcal{O}(\Delta t^{\boldsymbol{3}}) . \end{split} \tag{B.34}$$

For - D <sup>1</sup> <sup>2</sup> the first-order term vanishes and the scheme is of second order, while for - ¤ <sup>1</sup> <sup>2</sup> we only have a first-order scheme.

#### **B.3.4 Using Symbolic Software**

The previously mentioned truncation\_errormodule can be used to automate the Taylor series expansions and the process of collecting terms. Here is an example on possible use:

```
from truncation_error import DiffOp
from sympy import *
def decay():
    u, a = symbols('u a')
    diffop = DiffOp(u, independent_variable='t',
                    num_terms_Taylor_series=3)
    D1u = diffop.D(1) # symbol for du/dt
    ODE = D1u + a*u # define ODE
```

```
# Define schemes
FE = diffop['Dtp'] + a*u
CN = diffop['Dt' ] + a*u
BE = diffop['Dtm'] + a*u
theta = diffop['barDt'] + a*diffop['weighted_arithmetic_mean']
theta = sm.simplify(sm.expand(theta))
# Residuals (truncation errors)
R = {'FE': FE-ODE, 'BE': BE-ODE, 'CN': CN-ODE,
     'theta': theta-ODE}
return R
```
The returned dictionary becomes

```
decay: {
 'BE': D2u*dt/2 + D3u*dt**2/6,
 'FE': -D2u*dt/2 + D3u*dt**2/6,
 'CN': D3u*dt**2/24,
 'theta': -D2u*a*dt**2*theta**2/2 + D2u*a*dt**2*theta/2 -
           D2u*dt*theta + D2u*dt/2 + D3u*a*dt**3*theta**3/3 -
           D3u*a*dt**3*theta**2/2 + D3u*a*dt**3*theta/6 +
           D3u*dt**2*theta**2/2 - D3u*dt**2*theta/2 + D3u*dt**2/6,
}
```
The results are in correspondence with our hand-derived expressions.

#### **B.3.5 Empirical Verification of the Truncation Error**

The task of this section is to demonstrate how we can compute the truncation error R numerically. For example, the truncation error of the Forward Euler scheme applied to the decay ODE <sup>u</sup><sup>0</sup> D ua is

$$R^n = \left[D\_\iota^+ u\_\mathfrak{e} + a u\_\mathfrak{e}\right]^n. \tag{\text{B.35}}$$

If we happen to know the exact solution ue.t/, we can easily evaluate R<sup>n</sup> from the above formula.

To estimate how R varies with the discretization parameter t, which has been our focus in the previous mathematical derivations, we first make the assumption that <sup>R</sup> <sup>D</sup> Ct <sup>r</sup> for appropriate constants <sup>C</sup> and <sup>r</sup> and small enough t. The rate r can be estimated from a series of experiments where t is varied. Suppose we have <sup>m</sup> experiments .ti ; Ri/, <sup>i</sup> <sup>D</sup> 0; : : : ; m <sup>1</sup>. For two consecutive experiments .ti1; Ri1/ and .ti ; Ri/, a corresponding ri<sup>1</sup> can be estimated by

$$r\_{i-1} = \frac{\ln(R\_{i-1}/R\_i)}{\ln(\Delta t\_{i-1}/\Delta t\_i)},\tag{B.36}$$

for <sup>i</sup> <sup>D</sup> 1; : : : ; m <sup>1</sup>. Note that the truncation error Ri varies through the mesh, so (B.36) is to be applied pointwise. A complicating issue is that Ri and Ri<sup>1</sup> refer to different meshes. Pointwise comparisons of the truncation error at a certain point in all meshes therefore requires any computed R to be restricted to the *coarsest mesh* and that all finer meshes contain all the points in the coarsest mesh. Suppose we have N0 intervals in the coarsest mesh. Inserting a superscript n in (B.36), where n counts mesh points in the coarsest mesh, <sup>n</sup> D 0; : : : ; N0, leads to the formula

$$r\_{i-1}^n = \frac{\ln(R\_{i-1}^n / R\_i^n)}{\ln(\Delta t\_{i-1} / \Delta t\_i)} \,. \tag{B.37}$$

Experiments are most conveniently defined by N0 and a number of refinements m. Suppose each mesh has twice as many cells Ni as the previous one:

$$N\_i = \mathcal{Z}^i N\_0, \quad \Delta t\_i = T N\_i^{-1},$$

where Œ0; T is the total time interval for the computations. Suppose the computed Ri values on the mesh with Ni intervals are stored in an array R[i] (R being a list of arrays, one for each mesh). Restricting this Ri function to the coarsest mesh means extracting every Ni=N0 point and is done as follows:

```
stride = N[i]/N_0
R[i] = R[i][::stride]
```
The quantity R[i][n] now corresponds to R<sup>n</sup> i .

In addition to estimating <sup>r</sup> for the pointwise values of <sup>R</sup> D Ct <sup>r</sup> , we may also consider an integrated quantity on mesh i,

$$R\_{I,i} = \left(\Delta t\_i \sum\_{n=0}^{N\_i} (R\_i^n)^2\right)^{\frac{1}{2}} \approx \int\_0^T R\_i(t)dt \,. \tag{B.38}$$

The sequence RI;i , <sup>i</sup> <sup>D</sup> 0; : : : ; m <sup>1</sup>, is also expected to behave as Ct <sup>r</sup> , with the same <sup>r</sup> as for the pointwise quantity <sup>R</sup>, as t ! <sup>0</sup>.

The function below computes the Ri and RI;i quantities, plots them and compares with the theoretically derived truncation error (R\_a) if available.

```
import numpy as np
import scitools.std as plt
def estimate(truncation_error, T, N_0, m, makeplot=True):
    """
    Compute the truncation error in a problem with one independent
    variable, using m meshes, and estimate the convergence
    rate of the truncation error.
    The user-supplied function truncation_error(dt, N) computes
    the truncation error on a uniform mesh with N intervals of
    length dt::
      R, t, R_a = truncation_error(dt, N)
    where R holds the truncation error at points in the array t,
    and R_a are the corresponding theoretical truncation error
    values (None if not available).
```

```
The truncation_error function is run on a series of meshes
with 2**i*N_0 intervals, i=0,1,...,m-1.
The values of R and R_a are restricted to the coarsest mesh.
and based on these data, the convergence rate of R (pointwise)
and time-integrated R can be estimated empirically.
"""
N = [2**i*N_0 for i in range(m)]
R_I = np.zeros(m) # time-integrated R values on various meshes
R = [None]*m # time series of R restricted to coarsest mesh
R_a = [None]*m # time series of R_a restricted to coarsest mesh
dt = np.zeros(m)
legends_R = []; legends_R_a = [] # all legends of curves
for i in range(m):
    dt[i] = T/float(N[i])
    R[i], t, R_a[i] = truncation_error(dt[i], N[i])
    R_I[i] = np.sqrt(dt[i]*np.sum(R[i]**2))
    if i == 0:
        t_coarse = t # the coarsest mesh
    stride = N[i]/N_0
    R[i] = R[i][::stride] # restrict to coarsest mesh
    R_a[i] = R_a[i][::stride]
    if makeplot:
        plt.figure(1)
        plt.plot(t_coarse, R[i], log='y')
        legends_R.append('N=%d' % N[i])
        plt.hold('on')
        plt.figure(2)
        plt.plot(t_coarse, R_a[i] - R[i], log='y')
        plt.hold('on')
        legends_R_a.append('N=%d' % N[i])
if makeplot:
    plt.figure(1)
    plt.xlabel('time')
    plt.ylabel('pointwise truncation error')
    plt.legend(legends_R)
    plt.savefig('R_series.png')
    plt.savefig('R_series.pdf')
    plt.figure(2)
    plt.xlabel('time')
    plt.ylabel('pointwise error in estimated truncation error')
    plt.legend(legends_R_a)
    plt.savefig('R_error.png')
    plt.savefig('R_error.pdf')
```

```
# Convergence rates
r_R_I = convergence_rates(dt, R_I)
print 'R integrated in time; r:',
print ' '.join(['%.1f' % r for r in r_R_I])
R = np.array(R) # two-dim. numpy array
r_R = [convergence_rates(dt, R[:,n])[-1]
       for n in range(len(t_coarse))]
```
The first makeplot block demonstrates how to build up two figures in parallel, using plt.figure(i) to create and switch to figure number i. Figure numbers start at 1. A logarithmic scale is used on the y axis since we expect that R as a function of time (or mesh points) is exponential. The reason is that the theoretical estimate (B.30) contains u<sup>00</sup> e, which for the present model goes like eat . Taking the logarithm makes a straight line.

The code follows closely the previously stated mathematical formulas, but the statements for computing the convergence rates might deserve an explanation. The generic help function convergence\_rate(h, E) computes and returns ri<sup>1</sup>, <sup>i</sup> <sup>D</sup> 1; : : : ; m <sup>1</sup> from (B.37), given ti in <sup>h</sup> and <sup>R</sup><sup>n</sup> <sup>i</sup> in E:

```
def convergence_rates(h, E):
    from math import log
    r = [log(E[i]/E[i-1])/log(h[i]/h[i-1])
         for i in range(1, len(h))]
    return r
```
Calling r\_R\_I = convergence\_rates(dt, R\_I) computes the sequence of rates r0; r1;:::;rm<sup>2</sup> for the model RI t <sup>r</sup> , while the statements

```
R = np.array(R) # two-dim. numpy array
r_R = [convergence_rates(dt, R[:,n])[-1]
       for n in range(len(t_coarse))]
```
compute the final rate rm<sup>2</sup> for <sup>R</sup><sup>n</sup> t <sup>r</sup> at each mesh point tn in the coarsest mesh. This latter computation deserves more explanation. Since R[i][n] holds the estimated truncation error R<sup>n</sup> <sup>i</sup> on mesh i, at point tn in the coarsest mesh, R[:,n] picks out the sequence R<sup>n</sup> <sup>i</sup> for <sup>i</sup> <sup>D</sup> 0; : : : ; m <sup>1</sup>. The convergence\_rate function computes the rates at tn, and by indexing [-1] on the returned array from convergence\_rate, we pick the rate rm<sup>2</sup>, which we believe is the best estimation since it is based on the two finest meshes.

The estimate function is available in a module trunc\_empir.py. Let us apply this function to estimate the truncation error of the Forward Euler scheme. We need a function decay\_FE(dt, N) that can compute (B.35) at the points in a mesh with time step dt and N intervals:

**Fig. B.1** Estimated truncation error at mesh points for different meshes

```
import numpy as np
import trunc_empir
def decay_FE(dt, N):
    dt = float(dt)
    t = np.linspace(0, N*dt, N+1)
    u_e = I*np.exp(-a*t) # exact solution, I and a are global
    u = u_e # naming convention when writing up the scheme
    R = np.zeros(N)
    for n in range(0, N):
        R[n]=(u[n+1] - u[n])/dt + a*u[n]
    # Theoretical expression for the trunction error
    R_a = 0.5*I*(-a)**2*np.exp(-a*t)*dt
    return R, t[:-1], R_a[:-1]
if __name__ == '__main__':
    I = 1; a = 2 # global variables needed in decay_FE
    trunc_empir.estimate(decay_FE, T=2.5, N_0=6, m=4, makeplot=True)
```
The estimated rates for the integrated truncation error RI become 1.1, 1.0, and 1.0 for this sequence of four meshes. All the rates for R<sup>n</sup>, computed as r\_R, are also very close to 1 at all mesh points. The agreement between the theoretical formula (B.30) and the computed quantity (ref(B.35)) is very good, as illustrated in Fig. B.1 and B.2. The program trunc\_decay\_FE.py was used to perform the simulations and it can easily be modified to test other schemes (see also Exercise B.5).

**Fig. B.2** Difference between theoretical and estimated truncation error at mesh points for different meshes

#### **B.3.6 Increasing the Accuracy by Adding Correction Terms**

Now we ask the question: can we add terms in the differential equation that can help increase the order of the truncation error? To be precise, let us revisit the Forward Euler scheme for <sup>u</sup><sup>0</sup> D au, insert the exact solution <sup>u</sup>e, include a residual <sup>R</sup>, but also include new terms C:

$$[D\_{\iota}^{+}u\_{\mathfrak{c}} + au\_{\mathfrak{c}} = C + R]^{\iota}.\tag{B.39}$$

Inserting the Taylor expansions for ŒD<sup>C</sup> <sup>t</sup> ue <sup>n</sup> and keeping terms up to 3rd order in t gives the equation

$$\frac{1}{2}\mu\_{\mathfrak{c}}^{\prime\prime}(t\_n)\Delta t - \frac{1}{6}\mu\_{\mathfrak{c}}^{\prime\prime\prime}(t\_n)\Delta t^2 + \frac{1}{24}\mu\_{\mathfrak{c}}^{\prime\prime\prime}(t\_n)\Delta t^3 + \mathcal{O}(\Delta t^4) = C^n + R^n \dots$$

Can we find C<sup>n</sup> such that R<sup>n</sup> is *<sup>O</sup>*.t 2/? Yes, by setting

$$C^n = \frac{1}{2} u\_{\mathbf{c}}^{\prime\prime}(t\_n) \Delta t,$$

we manage to cancel the first-order term and

$$R^n = \frac{1}{6} \mathfrak{u}\_{\mathfrak{c}}^{\prime\prime}(t\_n) \Delta t^2 + \mathcal{O}(\Delta t^3) \dots$$

The correction term C<sup>n</sup> introduces <sup>1</sup> <sup>2</sup>tu<sup>00</sup> in the discrete equation, and we have to get rid of the derivative u00. One idea is to approximate u<sup>00</sup> by a second-order accurate finite difference formula, <sup>u</sup><sup>00</sup> .u<sup>n</sup>C<sup>1</sup>2u<sup>n</sup>Cu<sup>n</sup>1/=t <sup>2</sup>, but this introduces an additional time level with u<sup>n</sup><sup>1</sup>. Another approach is to rewrite u<sup>00</sup> in terms of u<sup>0</sup> or u using the ODE:

$$u' = -au \quad \Rightarrow \quad u'' = -au' = -a(-au) = a^2u'.$$

This means that we can simply set <sup>C</sup><sup>n</sup> D <sup>1</sup> <sup>2</sup>a2tun. We can then either solve the discrete equation

$$\left[D\_t^+u = -au + \frac{1}{2}a^2\Delta tu\right]^n,\tag{B.40}$$

or we can equivalently discretize the perturbed ODE

$$u' = -\hat{a}u, \quad \hat{a} = a\left(1 - \frac{1}{2}a\Delta t\right), \tag{B.41}$$

by a Forward Euler method. That is, we replace the original coefficient a by the perturbed coefficient <sup>a</sup>O. Observe that <sup>a</sup>O ! <sup>a</sup> as t ! <sup>0</sup>.

The Forward Euler method applied to (B.41) results in

$$\left[D\_t^+u = -a\left(1 - \frac{1}{2}a\Delta t\right)u\right]^n\dots$$

We can control our computations and verify that the truncation error of the scheme above is indeed *<sup>O</sup>*.t 2/.

Another way of revealing the fact that the perturbed ODE leads to a more accurate solution is to look at the amplification factor. Our scheme can be written as

$$Au^{n+1} = Au^n, \quad A = 1 - \hat{a}\Delta t = 1 - p + \frac{1}{2}p^2, \quad p = a\Delta t, \ldots$$

The amplification factor <sup>A</sup> as a function of <sup>p</sup> D at is seen to be the first three terms of the Taylor series for the exact amplification factor e<sup>p</sup>. The Forward Euler scheme for <sup>u</sup> D au gives only the first two terms <sup>1</sup> <sup>p</sup> of the Taylor series for <sup>e</sup><sup>p</sup>. That is, using <sup>a</sup>O increases the order of the accuracy in the amplification factor.

Instead of replacing <sup>u</sup><sup>00</sup> by <sup>a</sup>2u, we use the relation <sup>u</sup><sup>00</sup> D au<sup>0</sup> and add a term 1 <sup>2</sup>atu<sup>0</sup> in the ODE:

$$u' = -au - \frac{1}{2}a\Delta t u' \quad \Rightarrow \quad \left(1 + \frac{1}{2}a\Delta t\right)u' = -au\,\dots$$

Using a Forward Euler method results in

$$\left(1 + \frac{1}{2}a\Delta t\right)\frac{u^{n+1} - u^n}{\Delta t} = -au^n,$$

which after some algebra can be written as

$$u^{n+1} = \frac{1 - \frac{1}{2}a\Delta t}{1 + \frac{1}{2}a\Delta t}u^n\dots$$

This is the same formula as the one arising from a Crank-Nicolson scheme applied to <sup>u</sup><sup>0</sup> D au! It is now recommended to do Exercise B.6 and repeat the above steps to see what kind of correction term is needed in the Backward Euler scheme to make it second order.

The Crank-Nicolson scheme is a bit more challenging to analyze, but the ideas and techniques are the same. The discrete equation reads

$$[D\_l u = -au]^{n + \frac{1}{2}},$$

and the truncation error is defined through

$$[D\_l u\_{\mathfrak{C}} + a \overline{u\_{\mathfrak{C}}}' = C + R]^{n + \frac{1}{2}},$$

where we have added a correction term. We need to Taylor expand both the discrete derivative and the arithmetic mean with aid of (B.5)–(B.6) and (B.21)–(B.22), respectively. The result is

$$\frac{1}{24}u\_{\mathfrak{c}}^{'''}\left(t\_{n+\frac{1}{2}}\right)\Delta t^2 + \mathcal{O}(\Delta t^4) + \frac{a}{8}u\_{\mathfrak{c}}''\left(t\_{n+\frac{1}{2}}\right)\Delta t^2 + \mathcal{O}(\Delta t^4) = C^{n+\frac{1}{2}} + R^{n+\frac{1}{2}}.$$

The goal now is to make C<sup>n</sup><sup>C</sup> <sup>1</sup> <sup>2</sup> cancel the t <sup>2</sup> terms:

$$C^{n+\frac{1}{2}} = \frac{1}{24} \iota\_{\mathfrak{c}}^{\prime\prime\prime} \left( t\_{n+\frac{1}{2}} \right) \Delta t^2 + \frac{a}{8} \iota\_{\mathfrak{c}}^{\prime\prime} (t\_n) \Delta t^2 \dots$$

Using <sup>u</sup><sup>0</sup> D au, we have that <sup>u</sup><sup>00</sup> D <sup>a</sup>2u, and we find that <sup>u</sup><sup>000</sup> D a3u. We can therefore solve the perturbed ODE problem

$$u' = -\hat{a}u, \quad \hat{a} = a\left(1 - \frac{1}{12}a^2\Delta t^2\right),$$

by the Crank-Nicolson scheme and obtain a method that is of fourth order in t. Exercise B.7 encourages you to implement these correction terms and calculate empirical convergence rates to verify that higher-order accuracy is indeed obtained in real computations.

#### **B.3.7 Extension to Variable Coefficients**

Let us address the decay ODE with variable coefficients,

$$u'(t) = -a(t)u(t) + b(t),$$

discretized by the Forward Euler scheme,

$$\left[D\_l^+u = -au + b\right]^n. \tag{B.42}$$

The truncation error R is as always found by inserting the exact solution ue.t/ in the discrete scheme:

$$\left[D\_l^+u\_\mathfrak{e} + au\_\mathfrak{e} - b = R\right]^n. \tag{B.43}$$

Using (B.11)–(B.12),

$$u'\_{\mathfrak{C}}(t\_n) - \frac{1}{2}u''\_{\mathfrak{C}}(t\_n)\Delta t + \mathcal{O}(\Delta t^2) + a(t\_n)u\_{\mathfrak{C}}(t\_n) - b(t\_n) = R'' \dots$$

Because of the ODE,

$$
\mu\_{\mathfrak{c}}'(t\_n) + a(t\_n)\mu\_{\mathfrak{c}}(t\_n) - b(t\_n) = 0,
$$

we are left with the result

$$R^n = -\frac{1}{2} u\_{\mathfrak{C}}^{\prime\prime}(t\_n) \Delta t + \mathcal{O}(\Delta t^2) \,. \tag{B.44}$$

We see that the variable coefficients do not pose any additional difficulties in this case. Exercise B.8 takes the analysis above one step further to the Crank-Nicolson scheme.

#### **B.3.8 Exact Solutions of the Finite Difference Equations**

Having a mathematical expression for the numerical solution is very valuable in program verification, since we then know the exact numbers that the program should produce. Looking at the various formulas for the truncation errors in (B.5)–(B.6) and (B.25)–(B.26) in Sect. B.2.4, we see that all but two of the R expressions contain a second or higher order derivative of ue. The exceptions are the geometric and harmonic means where the truncation error involves u<sup>0</sup> <sup>e</sup> and even u<sup>e</sup> in case of the harmonic mean. So, apart from these two means, choosing ue to be a linear function of <sup>t</sup>, <sup>u</sup><sup>e</sup> <sup>D</sup> ct <sup>C</sup> <sup>d</sup> for constants <sup>c</sup> and <sup>d</sup>, will make the truncation error vanish since u<sup>00</sup> <sup>e</sup> <sup>D</sup> <sup>0</sup>. Consequently, the truncation error of a finite difference scheme will be zero since the various approximations used will all be exact. This means that the linear solution is an exact solution of the discrete equations.

In a particular differential equation problem, the reasoning above can be used to determine if we expect a linear ue to fulfill the discrete equations. To actually prove that this is true, we can either compute the truncation error and see that it vanishes, or we can simply insert <sup>u</sup>e.t/ D ct C <sup>d</sup> in the scheme and see that it fulfills the equations. The latter method is usually the simplest. It will often be necessary to add some source term to the ODE in order to allow a linear solution.

Many ODEs are discretized by centered differences. From Sect. B.2.4 we see that all the centered difference formulas have truncation errors involving u<sup>000</sup> <sup>e</sup> or higher-order derivatives. A quadratic solution, e.g., <sup>u</sup>e.t/ D <sup>t</sup> <sup>2</sup> C ct C <sup>d</sup>, will then make the truncation errors vanish. This observation can be used to test if a quadratic solution will fulfill the discrete equations. Note that a quadratic solution will not obey the equations for a Crank-Nicolson scheme for <sup>u</sup><sup>0</sup> D au C <sup>b</sup> because the approximation applies an arithmetic mean, which involves a truncation error with u<sup>00</sup> e.

#### **B.3.9 Computing Truncation Errors in Nonlinear Problems**

The general nonlinear ODE

$$u' = f(u, t),\tag{B.45}$$

can be solved by a Crank-Nicolson scheme

$$[D\_l u = \overline{f}^l]^{n + \frac{1}{2}} \,. \tag{B.46}$$

The truncation error is as always defined as the residual arising when inserting the exact solution ue in the scheme:

$$[D\_l u\_{\mathfrak{C}} - \overline{f}^l = R]^{n + \frac{1}{2}}.\tag{B.47}$$

Using (B.21)–(B.22) for <sup>f</sup> <sup>t</sup> results in

$$\begin{aligned} [\overline{f'}]^{n+\frac{1}{2}} &= \frac{1}{2} (f(u\_{\mathbf{c}}^{n}, t\_{n}) + f(u\_{\mathbf{c}}^{n+1}, t\_{n+1})) \\ &= f\left(u\_{\mathbf{c}}^{n+\frac{1}{2}}, t\_{n+\frac{1}{2}}\right) + \frac{1}{8} u\_{\mathbf{c}}'' \left(t\_{n+\frac{1}{2}}\right) \Delta t^{2} + \mathcal{O}(\Delta t^{4}) \dots \end{aligned}$$

With (B.5)–(B.6) the discrete equations (B.47) lead to

$$\begin{split} &u\_{\mathfrak{c}}'\left(t\_{n+\frac{1}{2}}\right) + \frac{1}{24}u\_{\mathfrak{c}}^{\prime\prime\prime}\left(t\_{n+\frac{1}{2}}\right)\Delta t^{2} - f\left(u\_{\mathfrak{c}}^{n+\frac{1}{2}}, t\_{n+\frac{1}{2}}\right) \\ & - \frac{1}{8}u\_{\mathfrak{c}}^{\prime\prime}\left(t\_{n+\frac{1}{2}}\right)\Delta t^{2} + \mathcal{O}(\Delta t^{4}) = R^{n+\frac{1}{2}} \ . \end{split}$$

Since u<sup>0</sup> <sup>e</sup>.tnC <sup>1</sup> 2 / f .u<sup>n</sup><sup>C</sup> <sup>1</sup> 2 <sup>e</sup> ; tnC <sup>1</sup> 2 / D <sup>0</sup>, the truncation error becomes

$$R^{n+\frac{1}{2}} = \left(\frac{1}{24}u\_{\mathbf{c}}^{\prime\prime\prime}\left(t\_{n+\frac{1}{2}}\right) - \frac{1}{8}u\_{\mathbf{c}}^{\prime\prime}\left(t\_{n+\frac{1}{2}}\right)\right)\Delta t^2 \dots$$

The computational techniques worked well even for this nonlinear ODE.

#### **B.4 Vibration ODEs**

#### **B.4.1 Linear Model Without Damping**

The next example on computing the truncation error involves the following ODE for vibration problems:

$$
\mu''(t) + \omega^2 \mu(t) = 0 \,. \tag{B.48}
$$

Here, ! is a given constant.

**The truncation error of a centered finite difference scheme** Using a standard, second-ordered, central difference for the second-order derivative in time, we have the scheme

$$[D\_l D\_l u + \omega^2 u = 0]''. \tag{B.49}$$

Inserting the exact solution ue in this equation and adding a residual R so that ue can fulfill the equation results in

$$\left[D\_{l}D\_{l}u\_{\mathfrak{C}} + \omega^{2}u\_{\mathfrak{C}} = R\right]^{n}.\tag{B.50}$$

To calculate the truncation error R<sup>n</sup>, we use (B.17)–(B.18), i.e.,

$$[D\_l D\_l u\_\mathbf{c}]^n = u\_\mathbf{c}^{\prime\prime}(t\_n) + \frac{1}{12} u\_\mathbf{c}^{\prime\prime\prime}(t\_n) \Delta t^2 + \mathcal{O}(\Delta t^4),$$

and the fact that u<sup>00</sup> <sup>e</sup>.t/ C !2ue.t/ D <sup>0</sup>. The result is

$$\mathcal{R}^{\boldsymbol{\pi}} = \frac{1}{12} \boldsymbol{u}\_{\mathbf{c}}^{\prime\prime\prime}(t\_{\boldsymbol{n}}) \Delta t^{2} + \mathcal{O}(\Delta t^{4}) \,. \tag{\text{B.51}}$$

**The truncation error of approximating** u<sup>0</sup> .0/ The initial conditions for (B.48) are u.0/ D <sup>I</sup> and <sup>u</sup><sup>0</sup> .0/ D <sup>V</sup> . The latter involves a finite difference approximation. The standard choice

$$\left[D\_{2l}u = V\right]^0,$$

where <sup>u</sup><sup>1</sup> is eliminated with the aid of the discretized ODE for <sup>n</sup> D <sup>0</sup>, involves a centered difference with an *<sup>O</sup>*.t 2/ truncation error given by (B.7)–(B.8). The simpler choice

$$[D\_\iota^+u = V]^0,$$

is based on a forward difference with a truncation error *O*.t/. A central question is if this initial error will impact the order of the scheme throughout the simulation. Exercise B.11 asks you to perform an experiment to investigate this question.

**Truncation error of the equation for the first step** We have shown that the truncation error of the difference used to approximate the initial condition u<sup>0</sup> .0/ D <sup>0</sup> is *<sup>O</sup>*.t 2/, but we can also investigate the difference equation used for the first step. In a truncation error setting, the right way to view this equation is not to use the initial condition ŒD2tu D V <sup>0</sup> to express <sup>u</sup><sup>1</sup> D <sup>u</sup><sup>1</sup> 2tV in order to eliminate u<sup>1</sup> from the discretized differential equation, but the other way around: the fundamental equation is the discretized initial condition ŒD2tu D V <sup>0</sup> and we use the discretized ODE ŒDtDt <sup>C</sup> !2u <sup>D</sup> 0<sup>0</sup> to eliminate <sup>u</sup><sup>1</sup> in the discretized initial condition. From ŒDtDt <sup>C</sup> !2u <sup>D</sup> 0<sup>0</sup> we have

$$u^{-1} = 2u^0 - u^1 - \Delta t^2 w^2 u^0,$$

which inserted in ŒD2tu <sup>D</sup> V <sup>0</sup> gives

$$\frac{u^1 - u^0}{\Delta t} + \frac{1}{2}\omega^2 \Delta t u^0 = V \,. \tag{\text{B.52}}$$

The first term can be recognized as a forward difference such that the equation can be written in operator notation as

$$\left[D\_t^+u + \frac{1}{2}\omega^2\Delta tu = V\right]^0.$$

The truncation error is defined as

$$\left[D\_t^+u\_{\mathbf{c}} + \frac{1}{2}\boldsymbol{\phi}^2\Delta t\boldsymbol{u\_{\mathbf{c}}} - V = R\right]^0 \dots$$

Using (B.11)–(B.12) with one more term in the Taylor series, we get that

$$u\_{\mathfrak{c}}'(0) + \frac{1}{2}u\_{\mathfrak{c}}''(0)\Delta t + \frac{1}{6}u\_{\mathfrak{c}}'''(0)\Delta t^2 + \mathcal{O}(\Delta t^3) + \frac{1}{2}\omega^2\Delta t u\_{\mathfrak{c}}(0) - V = R''\dots$$

Now, u<sup>0</sup> <sup>e</sup>.0/ D <sup>V</sup> and <sup>u</sup><sup>00</sup> <sup>e</sup>.0/ D !2ue.0/ so we get

$$R^n = \frac{1}{6} \mu\_{\mathfrak{e}}^{\prime\prime}(0) \Delta t^2 + \mathcal{O}(\Delta t^3) \, .$$

There is another way of analyzing the discrete initial condition, because eliminating u<sup>1</sup> via the discretized ODE can be expressed as

$$\left[D\_{2l}u + \Delta t(D\_l D\_l u - \omega^2 u) = V\right]^0. \tag{B.53}$$

Writing out (B.53) shows that the equation is equivalent to (B.52). The truncation error is defined by

$$\left[D\_{2l}u\_{\mathbf{c}} + \Delta t (D\_l D\_l u\_{\mathbf{c}} - \omega^2 u\_{\mathbf{c}}) = V + R\right]^0 .$$

Replacing the difference via (B.7)–(B.8) and (B.17)–(B.18), as well as using u0 <sup>e</sup>.0/ D <sup>V</sup> and <sup>u</sup><sup>00</sup> <sup>e</sup>.0/ D !2ue.0/, gives

$$R'' = \frac{1}{6} \mu\_{\text{c}}^{\prime\prime\prime}(0) \Delta t^2 + \mathcal{O}(\Delta t^3) \dots$$

**Computing correction terms** The idea of using correction terms to increase the order of R<sup>n</sup> can be applied as described in Sect. B.3.6. We look at

$$
\hbar \left[ D\_l D\_l u\_\mathbf{c} + \omega^2 u\_\mathbf{c} = C + R \right]^n,
$$

and observe that C<sup>n</sup> must be chosen to cancel the t <sup>2</sup> term in R<sup>n</sup>. That is,

$$C'' = \frac{1}{12} \mu\_{\mathbf{c}}^{\prime\prime\prime}(t\_n) \Delta t^2 \ .$$

To get rid of the 4th-order derivative we can use the differential equation: <sup>u</sup><sup>00</sup> <sup>D</sup> !2u, which implies <sup>u</sup><sup>0000</sup> D !4u. Adding the correction term to the ODE results in

$$
\mu'' + \omega^2 \left(1 - \frac{1}{12}\omega^2 \Delta t^2\right)\mu = 0.\tag{B.54}
$$

Solving this equation by the standard scheme

$$\left[D\_t D\_t u + \alpha^2 \left(1 - \frac{1}{12} \alpha^2 \Delta t^2\right) u = 0\right]^n J$$

will result in a scheme with truncation error *<sup>O</sup>*.t 4/.

We can use another set of arguments to justify that (B.54) leads to a higher-order method. Mathematical analysis of the scheme (B.49) reveals that the numerical frequency !Q is (approximately as t ! <sup>0</sup>)

$$
\tilde{\phi} = \phi \left( \mathbf{l} + \frac{1}{24} \boldsymbol{\phi}^2 \Delta t^2 \right) \cdot \mathbf{l}
$$

One can therefore attempt to replace ! in the ODE by a slightly smaller ! since the numerics will make it larger:

$$\left[u'' + \left(\alpha \left(1 - \frac{1}{24}\alpha^2 \Delta t^2\right)\right)^2 u = 0\right]''.$$

Expanding the squared term and omitting the higher-order term t <sup>4</sup> gives exactly the ODE (B.54). Experiments show that u<sup>n</sup> is computed to 4th order in t. You can confirm this by running a little program in the vib directory:

```
from vib_undamped import convergence_rates, solver_adjust_w
r = convergence_rates(
      m=5, solver_function=solver_adjust_w, num_periods=8)
```
One will see that the rates r lie around 4.

#### **B.4.2 Model with Damping and Nonlinearity**

The model (B.48) can be extended to include damping ˇu<sup>0</sup> , a nonlinear restoring (spring) force s.u/, and some known excitation force F .t/:

$$
\beta m u'' + \beta u' + s(u) = F(t) \,. \tag{B.S5}
$$

The coefficient m usually represents the mass of the system. This governing equation can be discretized by centered differences:

$$\left[mD\_1D\_1u + \beta D\_{21}u + s(u) = F\right]^n. \tag{B.56}$$

The exact solution ue fulfills the discrete equations with a residual term:

$$\left[mD\_{1}D\_{1}u\_{\mathfrak{c}} + \beta D\_{21}u\_{\mathfrak{c}} + s(u\_{\mathfrak{c}}) = F + R\right]^{n}.\tag{B.57}$$

Using (B.17)–(B.18) and (B.7)–(B.8) we get

$$\begin{split} [mD\_{l}D\_{l}u\_{\mathfrak{c}} + \beta D\_{2l}u\_{\mathfrak{c}}]^{n} &= m u\_{\mathfrak{c}}^{\prime\prime}(t\_{n}) + \beta u\_{\mathfrak{c}}^{\prime}(t\_{n}) \\ &+ \left(\frac{m}{12}u\_{\mathfrak{c}}^{\prime\prime\prime}(t\_{n}) + \frac{\beta}{6}u\_{\mathfrak{c}}^{\prime\prime\prime}(t\_{n})\right) \Delta t^{2} + \mathcal{O}(\Delta t^{4}) .\end{split}$$

Combining this with the previous equation, we can collect the terms

$$
\beta m u\_{\mathfrak{c}}''(t\_n) + \beta u\_{\mathfrak{c}}'(t\_n) + \alpha^2 u\_{\mathfrak{c}}(t\_n) + s(u\_{\mathfrak{c}}(t\_n)) - F'',
$$

and set this sum to zero because ue solves the differential equation. We are left with the truncation error

$$R^n = \left(\frac{m}{12}u\_{\text{c}}^{\prime\prime\prime}(t\_n) + \frac{\beta}{6}u\_{\text{c}}^{\prime\prime\prime}(t\_n)\right)\Delta t^2 + \mathcal{O}(\Delta t^4),\tag{B.58}$$

so the scheme is of second order.

According to (B.58), we can add correction terms

$$C'' = \left(\frac{m}{12}\mu\_{\text{c}}^{\prime\prime\prime}(t\_n) + \frac{\beta}{6}\mu\_{\text{c}}^{\prime\prime\prime}(t\_n)\right)\Delta t^2,$$

to the right-hand side of the ODE to obtain a fourth-order scheme. However, expressing u<sup>0000</sup> and u<sup>000</sup> in terms of lower-order derivatives is now harder because the differential equation is more complicated:

$$\begin{split} u^{\prime\prime\prime} &= \frac{1}{m} (F^{\prime} - \beta u^{\prime\prime} - s^{\prime}(u)u^{\prime}), \\ u^{\prime\prime\prime} &= \frac{1}{m} (F^{\prime\prime} - \beta u^{\prime\prime} - s^{\prime}(u)(u^{\prime})^2 - s^{\prime}(u)u^{\prime\prime}), \\ &= \frac{1}{m} (F^{\prime\prime} - \beta \frac{1}{m} (F^{\prime} - \beta u^{\prime\prime} - s^{\prime}(u)u^{\prime}) - s^{\prime\prime}(u)(u^{\prime})^2 - s^{\prime}(u)u^{\prime\prime}). \end{split}$$

It is not impossible to discretize the resulting modified ODE, but it is up to debate whether correction terms are feasible and the way to go. Computing with a smaller t is usually always possible in these problems to achieve the desired accuracy.

#### **B.4.3 Extension to Quadratic Damping**

Instead of the linear damping term ˇu<sup>0</sup> in (B.55) we now consider quadratic damping <sup>ˇ</sup>ju<sup>0</sup> ju0 :

$$
tau'' + \beta |u'|u' + s(u) = F(t) \,. \tag{B.59}
$$

A centered difference for u<sup>0</sup> gives rise to a nonlinearity, which can be linearized using a geometric mean: <sup>Œ</sup>ju<sup>0</sup> ju0 n jŒu<sup>0</sup> n 1 <sup>2</sup> jŒu<sup>0</sup> nC 1 <sup>2</sup> . The resulting scheme becomes

$$|\left[m\,D\_{l}\,D\_{l}u\right]^{n}+\beta|\left[D\_{l}u\right]^{n-\frac{1}{2}}|\left[D\_{l}u\right]^{n+\frac{1}{2}}+s(u^{n})=F^{n}.\tag{B.60}$$

The truncation error is defined through

$$[mD\_{\iota}D\_{\iota}u\_{\mathfrak{c}}]^{n} + \beta |[D\_{\iota}u\_{\mathfrak{c}}]^{n - \frac{1}{2}}|[D\_{\iota}u\_{\mathfrak{c}}]^{n + \frac{1}{2}} + s(u\_{\mathfrak{c}}^{n}) - F^{n} = R^{n} \,. \tag{B.61}$$

We start with expressing the truncation error of the geometric mean. According to (B.23)–(B.24),

$$\begin{split} \left| \left[ D\_{\boldsymbol{l}} \boldsymbol{u}\_{\mathsf{c}} \right]^{n-\frac{1}{2}} \right| \left[ D\_{\boldsymbol{l}} \boldsymbol{u}\_{\mathsf{c}} \right]^{n+\frac{1}{2}} &= \left[ \left| D\_{\boldsymbol{l}} \boldsymbol{u}\_{\mathsf{c}} \right| D\_{\boldsymbol{l}} \boldsymbol{u}\_{\mathsf{c}} \right]^{n} - \frac{1}{4} \boldsymbol{u}\_{\mathsf{c}}'(t\_{n})^{2} \Delta t^{2} \\ &+ \frac{1}{4} \boldsymbol{u}\_{\mathsf{c}}(t\_{n}) \boldsymbol{u}\_{\mathsf{c}}''(t\_{n}) \Delta t^{2} + \mathcal{O}(\Delta t^{4}) \, . \end{split}$$

Using (B.5)–(B.6) for the Dtue factors results in

$$\begin{aligned} & \left[ |D\_l u\_{\mathfrak{C}}| D\_l u\_{\mathfrak{C}} \right]^n \\ &= \left| u\_{\mathfrak{c}}' + \frac{1}{24} u\_{\mathfrak{c}}'''(t\_n) \Delta t^2 + \mathcal{O}(\Delta t^4) \right| \left( u\_{\mathfrak{c}}' + \frac{1}{24} u\_{\mathfrak{c}}'''(t\_n) \Delta t^2 + \mathcal{O}(\Delta t^4) \right) . \end{aligned}$$

We can remove the absolute value since it essentially gives a factor 1 or 1 only. Calculating the product, we have the leading-order terms

$$[D\_l u\_\mathfrak{e} D\_l u\_\mathfrak{e}]'' = \left(u\_\mathfrak{e}'(t\_n)\right)^2 + \frac{1}{12} u\_\mathfrak{e}(t\_n) u\_\mathfrak{e}''''(t\_n) \Delta t^2 + \mathcal{O}(\Delta t^4) \, .$$

With

$$m[D\_l D\_l u\_\mathfrak{c}]'' = m u\_\mathfrak{c}''(t\_n) + \frac{m}{12} u\_\mathfrak{c}'''(t\_n) \Delta t^2 + \mathcal{O}(\Delta t^4),$$

and using the differential equation on the form mu<sup>00</sup> C ˇ.u<sup>0</sup> /2 C s.u/ D <sup>F</sup>, we end up with

$$R^n = \left(\frac{m}{12}u\_{\text{c}}^{\prime\prime\prime}(t\_n) + \frac{\beta}{12}u\_{\text{c}}(t\_n)u\_{\text{c}}^{\prime\prime\prime}(t\_n)\right)\Delta t^2 + \mathcal{O}(\Delta t^4)\,\,.$$

This result demonstrates that we have second-order accuracy also with quadratic damping. The key elements that lead to the second-order accuracy is that the difference approximations are *<sup>O</sup>*.t 2/ *and* the geometric mean approximation is also *<sup>O</sup>*.t 2/.

#### **B.4.4 The General Model Formulated as First-Order ODEs**

The second-order model (B.59) can be formulated as a first-order system,

$$v' = \frac{1}{m} \left( F(t) - \beta |v| v - s(u) \right), \tag{B.62}$$

$$u' = v\,. \tag{\text{B.63}}$$

The system (B.63)–(B.63) can be solved either by a forward-backward scheme (the Euler-Cromer method) or a centered scheme on a staggered mesh.

**A centered scheme on a staggered mesh** We now introduce a staggered mesh where we seek u at mesh points tn and v at points t nC 1 <sup>2</sup> in between the <sup>u</sup> points. The staggered mesh makes it easy to formulate centered differences in the system (B.63)–(B.63):

$$[D\_l u = v]^{n - \frac{1}{2}},\tag{B.64}$$

$$
\left[D\_l v = \frac{1}{m}(F(t) - \beta|v|v - s(u))\right]^n. \tag{B.65}
$$

The term jv<sup>n</sup>jv<sup>n</sup> causes trouble since <sup>v</sup><sup>n</sup> is not computed, only <sup>v</sup><sup>n</sup> <sup>1</sup> <sup>2</sup> and v<sup>n</sup><sup>C</sup> <sup>1</sup> <sup>2</sup> . Using geometric mean, we can express jv<sup>n</sup>jv<sup>n</sup> in terms of known quantities: jv<sup>n</sup>jv<sup>n</sup> jv<sup>n</sup> <sup>1</sup> <sup>2</sup> jv<sup>n</sup><sup>C</sup> <sup>1</sup> <sup>2</sup> . We then have

$$\left[D\_l u\right]^{n-\frac{1}{2}} = v^{n-\frac{1}{2}},\tag{B.66}$$

$$\mathbb{E}\left[D\_{\iota}v\right]^{n} = \frac{1}{m}\left(F(t\_{n}) - \beta \left|v^{n-\frac{1}{2}}\right|v^{n+\frac{1}{2}} - s(u^{n})\right) \,. \tag{B.67}$$

The truncation error in each equation fulfills

$$\begin{aligned} [[D\_t u\_\mathfrak{e}]^{n-\frac{1}{2}} &= v\_\mathfrak{e} \left( t\_{n-\frac{1}{2}} \right) + R\_u^{n-\frac{1}{2}},\\ [D\_t v\_\mathfrak{e}]^n &= \frac{1}{m} \left( F(t\_n) - \beta \left| v\_\mathfrak{e} \left( t\_{n-\frac{1}{2}} \right) \right| v\_\mathfrak{e} \left( t\_{n+\frac{1}{2}} \right) - s(u^n) \right) + R\_v^n. \end{aligned}$$

The truncation error of the centered differences is given by (B.5)–(B.6), and the geometric mean approximation analysis can be taken from (B.23)–(B.24). These results lead to

$$u'\_{\mathfrak{c}}\left(t\_{n-\frac{1}{2}}\right) + \frac{1}{24}u\_{\mathfrak{c}}^{\prime\prime}\left(t\_{n-\frac{1}{2}}\right)\Delta t^2 + \mathcal{O}(\Delta t^4) = v\_{\mathfrak{c}}\left(t\_{n-\frac{1}{2}}\right) + R\_u^{n-\frac{1}{2}},$$

and

$$v\_{\mathfrak{c}}'(t\_n) = \frac{1}{m}(F(t\_n) - \beta|v\_{\mathfrak{c}}(t\_n)|v\_{\mathfrak{c}}(t\_n) + \mathcal{O}(\Delta t^2) - s(\mu^n)) + R\_v^n \dots$$

The ODEs fulfilled by ue and ve are evident in these equations, and we achieve second-order accuracy for the truncation error in both equations:

$$R\_u^{n - \frac{1}{2}} = \mathcal{O}(\Delta t^2), \quad R\_v^n = \mathcal{O}(\Delta t^2) \dots$$

#### **B.5 Wave Equations**

#### **B.5.1 Linear Wave Equation in 1D**

The standard, linear wave equation in 1D for a function u.x; t/ reads

$$\frac{\partial^2 u}{\partial t^2} = c^2 \frac{\partial^2 u}{\partial x^2} + f(\mathbf{x}, t), \quad \mathbf{x} \in (0, L), \ t \in (0, T], \tag{\text{B.68}}$$

where c is the constant wave velocity of the physical medium in Œ0; L. The equation can also be more compactly written as

$$u\_{ll} = c^2 u\_{xx} + f, \quad x \in (0, L), \ t \in (0, T]. \tag{B.69}$$

Centered, second-order finite differences are a natural choice for discretizing the derivatives, leading to

$$\left[D\_l D\_l u = c^2 D\_x D\_x u + f\right]\_l^n. \tag{B.70}$$

Inserting the exact solution ue.x; t/ in (B.70) makes this function fulfill the equation if we add the term R:

$$\left[D\_{l}D\_{l}\mu\_{\mathfrak{C}} = c^{2}D\_{x}D\_{x}\mu\_{\mathfrak{C}} + f + R\right]\_{l}^{n}.\tag{B.71}$$

Our purpose is to calculate the truncation error R. From (B.17)–(B.18) we have that

$$[D\_I D\_I \mathbf{u}\_{\mathbf{c}}]\_i^n = \mathbf{u}\_{\mathbf{c}, \iota \iota}(\mathbf{x}\_i, t\_n) + \frac{1}{12} \mathbf{u}\_{\mathbf{c}, \iota \iota \iota \iota}(\mathbf{x}\_i, t\_n) \Delta t^2 + \mathcal{O}(\Delta t^4),$$

when we use a notation taking into account that ue is a function of two variables and that derivatives must be partial derivatives. The notation ue;tt means @2ue=@t <sup>2</sup>.

The same formula may also be applied to the x-derivative term:

$$\mathbb{E}\left[D\_{\mathbf{x}}D\_{\mathbf{x}}u\_{\mathbf{c}}\right]\_{l}^{n} = u\_{\mathbf{c},\mathbf{x}\boldsymbol{\epsilon}}(\mathbf{x}\_{l},t\_{n}) + \frac{1}{12}u\_{\mathbf{c},\mathbf{x}\boldsymbol{\epsilon}\boldsymbol{\varepsilon}\boldsymbol{\epsilon}}(\mathbf{x}\_{l},t\_{n})\Delta\mathbf{x}^{2} + \mathcal{O}(\Delta\boldsymbol{\omega}^{4})\dots$$

Equation (B.71) now becomes

$$\begin{split} u\_{\mathfrak{e},ll} + \frac{1}{12} u\_{\mathfrak{e},llll}(\mathbf{x}\_{i}, t\_{n}) \Delta t^{2} &= c^{2} u\_{\mathfrak{e},\mathbf{x}\mathbf{x}} + c^{2} \frac{1}{12} u\_{\mathfrak{e},\mathbf{x}\mathbf{x}\mathbf{x}\mathbf{x}}(\mathbf{x}\_{i}, t\_{n}) \Delta \mathbf{x}^{2} + f(\mathbf{x}\_{i}, t\_{n}) \\ &+ \mathcal{O}(\Delta t^{4}, \Delta \mathbf{x}^{4}) + R\_{i}^{n} .\end{split}$$

Because ue fulfills the partial differential equation (PDE) (B.69), the first, third, and fifth term cancel out, and we are left with

$$R\_i'' = \frac{1}{12} \mu\_{\mathbf{c}, \text{III}}(\mathbf{x}\_i, t\_n) \Delta t^2 - c^2 \frac{1}{12} \mu\_{\mathbf{c}, \text{xxx}}(\mathbf{x}\_i, t\_n) \Delta \mathbf{x}^2 + \mathcal{O}(\Delta t^4, \Delta \mathbf{x}^4), \quad (\text{B.72})$$

showing that the scheme (B.70) is of second order in the time and space mesh spacing.

#### **B.5.2 Finding Correction Terms**

Can we add correction terms to the PDE and increase the order of R<sup>n</sup> <sup>i</sup> in (B.72)? The starting point is

$$\mathbb{E}\left[D\_{l}D\_{l}u\_{\mathfrak{C}}=c^{2}D\_{x}D\_{x}u\_{\mathfrak{C}}+f+C+R\right]^{n}.\tag{B.73}$$

From the previous analysis we simply get (B.72) again, but now with C:

$$R\_i^{\pi} + C\_i^{\pi} = \frac{1}{12} \mu\_{\mathsf{C},III}(\mathbf{x}\_i, t\_n) \Delta t^2 - c^2 \frac{1}{12} \mu\_{\mathsf{C},\mathbf{x}\times\mathbf{x}}(\mathbf{x}\_i, t\_n) \Delta x^2 + \mathcal{O}(\Delta t^4, \Delta x^4) \,. \tag{\text{B.74}}$$

The idea is to let C<sup>n</sup> <sup>i</sup> cancel the t <sup>2</sup> and x<sup>2</sup> terms to make R<sup>n</sup> <sup>i</sup> <sup>D</sup> *<sup>O</sup>*.t 4; x4/:

$$C\_i^n = \frac{1}{12} u\_{\mathfrak{C},III}(\mathbf{x}\_i, t\_n) \Delta t^2 - c^2 \frac{1}{12} u\_{\mathfrak{C}, \text{xxxxx}}(\mathbf{x}\_i, t\_n) \Delta \mathbf{x}^2 \,.$$

Essentially, it means that we add a new term

$$C = \frac{1}{12} \left( u\_{III} \Delta t^2 - c^2 u\_{xxxx} \Delta x^2 \right),$$

to the right-hand side of the PDE. We must either discretize these 4th-order derivatives directly or rewrite them in terms of lower-order derivatives with the aid of the PDE. The latter approach is more feasible. From the PDE we have the operator equality

$$
\frac{\partial^2}{\partial t^2} = c^2 \frac{\partial^2}{\partial x^2},
$$

so

$$
\mu\_{IIIl} = c^2 \mu\_{\times \iota II}, \quad \mu\_{\times \times \iota} = c^{-2} \mu\_{II \times \iota}.
$$

Assuming <sup>u</sup> is smooth enough, so that uxxtt <sup>D</sup> uttxx, these relations lead to

$$C = \frac{1}{12}((c^2 \Delta t^2 - \Delta x^2)u\_{xx})\_{II} \dots$$

A natural discretization is

$$C\_i'' = \frac{1}{12}((c^2 \Delta t^2 - \Delta x^2)[D\_x D\_x D\_l D\_l u]\_i^n \dots$$

Writing out ŒDxDxDtDtu<sup>n</sup> <sup>i</sup> as ŒDxDx.DtDtu/<sup>n</sup> <sup>i</sup> gives

$$\begin{split} \frac{1}{\Delta t^{2}} & \left( \frac{u\_{i+1}^{n+1} - 2u\_{i+1}^{n} + u\_{i+1}^{n-1}}{\Delta x^{2}} \\ & - 2 \frac{u\_{i}^{n+1} - 2u\_{i}^{n} + u\_{i}^{n-1}}{\Delta x^{2}} + \frac{u\_{i-1}^{n+1} - 2u\_{i-1}^{n} + u\_{i-1}^{n-1}}{\Delta x^{2}} \right). \end{split}$$

Now the unknown values u<sup>n</sup>C<sup>1</sup> <sup>i</sup>C<sup>1</sup> , <sup>u</sup><sup>n</sup>C<sup>1</sup> <sup>i</sup> , and u<sup>n</sup>C<sup>1</sup> <sup>i</sup><sup>1</sup> are *coupled*, and we must solve a tridiagonal system to find them. This is in principle straightforward, but it results in an implicit finite difference scheme, while we had a convenient explicit scheme without the correction terms.

#### **B.5.3 Extension to Variable Coefficients**

Now we address the variable coefficient version of the linear 1D wave equation,

$$
\frac{\partial^2 u}{\partial t^2} = \frac{\partial}{\partial x} \left( \lambda(x) \frac{\partial u}{\partial x} \right),
$$

or written more compactly as

$$
\mu\_{ll} = (\lambda u\_{\times})\_{\times} \,. \tag{B.75}
$$

The discrete counterpart to this equation, using arithmetic mean for and centered differences, reads

$$\left[D\_I D\_l u = D\_\chi \overline{\lambda}^\chi D\_\chi u\right]\_l^n \,. \tag{B.76}$$

The truncation error is the residual R in the equation

$$\left[D\_I D\_I u\_\mathfrak{C} = D\_\chi \overline{\lambda}^\chi D\_\chi u\_\mathfrak{C} + R\right]\_i^n. \tag{B.77}$$

The difficulty with (B.77) is how to compute the truncation error of the term ŒDx x Dxue n i .

We start by writing out the outer operator:

$$\left[D\_{\boldsymbol{x}}\overline{\boldsymbol{\lambda}}^{\boldsymbol{x}}D\_{\boldsymbol{x}}\boldsymbol{u}\_{\mathcal{C}}\right]\_{i}^{n}=\frac{1}{\Delta\boldsymbol{x}}\left(\left[\overline{\boldsymbol{\lambda}}^{\boldsymbol{x}}D\_{\boldsymbol{x}}\boldsymbol{u}\_{\mathcal{C}}\right]\_{i+\frac{1}{2}}^{n}-\left[\overline{\boldsymbol{\lambda}}^{\boldsymbol{x}}D\_{\boldsymbol{x}}\boldsymbol{u}\_{\mathcal{C}}\right]\_{i-\frac{1}{2}}^{n}\right).\tag{B.78}$$

With the aid of (B.5)–(B.6) and (B.21)–(B.22) we have

ŒDxue n iC 1 2 <sup>D</sup> <sup>u</sup>e;x xiC <sup>1</sup> 2 ; tn C 1 24 <sup>u</sup>e;xxx xiC <sup>1</sup> 2 ; tn x<sup>2</sup> C *<sup>O</sup>*.x<sup>4</sup> /; h xi iC 1 2 D xiC <sup>1</sup> 2 C 1 8 00 xiC <sup>1</sup> 2 x<sup>2</sup> C *<sup>O</sup>*.x<sup>4</sup> /; h x Dxue in iC 1 2 D - xiC <sup>1</sup> 2 C 1 8 00 xiC <sup>1</sup> 2 x<sup>2</sup> C *<sup>O</sup>*.x<sup>4</sup> / - - <sup>u</sup>e;x xiC <sup>1</sup> 2 ; tn C 1 24 <sup>u</sup>e;xxx xiC <sup>1</sup> 2 ; tn x<sup>2</sup> C *<sup>O</sup>*.x<sup>4</sup> / D xiC <sup>1</sup> 2 <sup>u</sup>e;x xiC <sup>1</sup> 2 ; tn C xiC <sup>1</sup> 2 1 24 <sup>u</sup>e;xxx xiC <sup>1</sup> 2 ; tn x<sup>2</sup> <sup>C</sup> <sup>u</sup>e;x xiC <sup>1</sup> 2 ; tn 1 8 00 xiC <sup>1</sup> 2 x<sup>2</sup> C *<sup>O</sup>*.x<sup>4</sup> / <sup>D</sup> Œue;x n iC 1 2 C <sup>G</sup><sup>n</sup> iC 1 2 x<sup>2</sup> C *<sup>O</sup>*.x<sup>4</sup> /;

where we have introduced the short form

$$G\_{i+\frac{1}{2}}^n = \frac{1}{24} \mu\_{\mathfrak{C}, \text{xxx}} \left( \mathbf{x}\_{i+\frac{1}{2}}, t\_n \right) \lambda \left( \mathbf{x}\_{i+\frac{1}{2}} \right) + \mu\_{\mathfrak{C}, \text{x}} \left( \mathbf{x}\_{i+\frac{1}{2}}, t\_n \right) \frac{1}{8} \lambda'' \left( \mathbf{x}\_{i+\frac{1}{2}} \right) \dots$$

Similarly, we find that

$$\left[\overline{\lambda}^{\chi}D\_{\ge}u\_{\mathfrak{C}}\right]\_{i-\frac{1}{2}}^n = \left[\lambda u\_{\mathfrak{C},\chi}\right]\_{i-\frac{1}{2}}^n + G\_{i-\frac{1}{2}}^n \Delta x^2 + \mathcal{O}(\Delta x^4) \dots$$

Inserting these expressions in the outer operator (B.78) results in

$$\begin{split} \left[D\_{\boldsymbol{x}}\overline{\lambda}^{\boldsymbol{x}}D\_{\boldsymbol{x}}u\_{\mathfrak{c}}\right]\_{i}^{n} &= \frac{1}{\Delta\boldsymbol{x}} \left(\left[\overline{\lambda}^{\boldsymbol{x}}D\_{\boldsymbol{x}}u\_{\mathfrak{c}}\right]\_{i+\frac{1}{2}}^{n} - \left[\overline{\lambda}^{\boldsymbol{x}}D\_{\boldsymbol{x}}u\_{\mathfrak{c}}\right]\_{i-\frac{1}{2}}^{n}\right) \\ &= \frac{1}{\Delta\boldsymbol{x}} \left(\left[\lambda u\_{\mathfrak{c},\boldsymbol{x}}\right]\_{i+\frac{1}{2}}^{n} + G\_{i+\frac{1}{2}}^{n}\Delta\boldsymbol{x}^{2} - \left[\lambda u\_{\mathfrak{c},\boldsymbol{x}}\right]\_{i-\frac{1}{2}}^{n} - G\_{i-\frac{1}{2}}^{n}\Delta\boldsymbol{x}^{2} + \mathcal{O}(\Delta\boldsymbol{x}^{4})\right) \\ &= \left[D\_{\boldsymbol{x}}\lambda u\_{\mathfrak{c},\boldsymbol{x}}\right]\_{i}^{n} + \left[D\_{\boldsymbol{x}}G\right]\_{i}^{n}\Delta\boldsymbol{x}^{2} + \mathcal{O}(\Delta\boldsymbol{x}^{4})\. \end{split}$$

The reason for *<sup>O</sup>*.x4/ in the remainder is that there are coefficients in front of this term, say Hx<sup>4</sup>, and the subtraction and division by x results in ŒDxH<sup>n</sup> <sup>i</sup> x<sup>4</sup>.

We can now use (B.5)–(B.6) to express the Dx operator in ŒDxue;x n <sup>i</sup> as a derivative and a truncation error:

$$\mathbb{E}\left[D\_{\mathbf{x}}\lambda u\_{\mathbf{c},\mathbf{x}}\right]\_i^n = \frac{\partial}{\partial \mathbf{x}}\lambda(\mathbf{x}\_i)u\_{\mathbf{c},\mathbf{x}}(\mathbf{x}\_i,t\_n) + \frac{1}{24}(\lambda u\_{\mathbf{c},\mathbf{x}})\_{\text{xxx}}(\mathbf{x}\_i,t\_n)\Delta \mathbf{x}^2 + \mathcal{O}(\Delta \mathbf{x}^4)\dots$$

Expressions like ŒDxG<sup>n</sup> <sup>i</sup> x<sup>2</sup> can be treated in an identical way,

$$[[D\_{\mathbf{x}}G]\_i^n \Delta \mathbf{x}^2 = G\_{\mathbf{x}}(\mathbf{x}\_i, t\_n) \Delta \mathbf{x}^2 + \frac{1}{24} G\_{\mathbf{xxx}}(\mathbf{x}\_i, t\_n) \Delta \mathbf{x}^4 + \mathcal{O}(\Delta \mathbf{x}^4) \Box$$

There will be a number of terms with the x<sup>2</sup> factor. We lump these now into *O*.x2/. The result of the truncation error analysis of the spatial derivative is therefore summarized as

$$\left[D\_x \overline{\lambda}^\times D\_x u\_\mathbf{c}\right]\_i^n = \frac{\partial}{\partial \boldsymbol{\alpha}} \lambda(\boldsymbol{x}\_i) u\_{\mathbf{c}, \boldsymbol{x}}(\boldsymbol{x}\_i, t\_n) + \mathcal{O}(\Delta \boldsymbol{x}^2) \dots$$

After having treated the ŒDtDtue n <sup>i</sup> term as well, we achieve

$$R\_i'' = \mathcal{O}(\Delta x^2) + \frac{1}{12} u\_{\mathfrak{C},III}(\chi\_i, t\_n) \Delta t^2 \dots$$

The main conclusion is that the scheme is of second-order in time and space also in this variable coefficient case. The key ingredients for second order are the centered differences and the arithmetic mean for : all those building blocks feature secondorder accuracy.

#### **B.5.4 Linear Wave Equation in 2D/3D**

The two-dimensional extension of (B.68) takes the form

$$\frac{\partial^2 u}{\partial t^2} = c^2 \left( \frac{\partial^2 u}{\partial \mathbf{x}^2} + \frac{\partial^2 u}{\partial \mathbf{y}^2} \right) + f(\mathbf{x}, \mathbf{y}, t), \quad (\mathbf{x}, \mathbf{y}) \in (0, L) \times (0, H), \ t \in (0, T], \tag{\text{B.79}}$$

where now c.x; y/ is the constant wave velocity of the physical medium Œ0; L - Œ0; H. In compact notation, the PDE (B.79) can be written

$$u\_{ll} = c^2(u\_{xx} + u\_{yy}) + f(\mathbf{x}, \mathbf{y}, t), \quad (\mathbf{x}, \mathbf{y}) \in (0, L) \times (0, H), \ t \in (0, T], \text{ (B.80)}$$

in 2D, while the 3D version reads

$$u\_{ll} = c^2(u\_{xx} + u\_{yy} + u\_{zz}) + f(\mathbf{x}, \mathbf{y}, z, t), \tag{\text{B.81}}$$

for .x; y; z/ 2 .0; L/ - .0; H / -.0; B/ and <sup>t</sup> 2 .0; T .

Approximating the second-order derivatives by the standard formulas (B.17)– (B.18) yields the scheme

$$\left[D\_{l}D\_{l}\boldsymbol{u} = c^{2}(D\_{x}D\_{x}\boldsymbol{u} + D\_{y}D\_{y}\boldsymbol{u} + D\_{z}D\_{z}\boldsymbol{u}) + f\right]\_{l,j,k}^{n}.\tag{B.82}$$

The truncation error is found from

$$\mathcal{E}\left[D\_l D\_l u\_\mathfrak{C} = c^2 (D\_\chi D\_\chi u\_\mathfrak{C} + D\_\chi D\_\chi u\_\mathfrak{C} + D\_z D\_z u\_\mathfrak{C}) + f + R\right]\_{l,j,k}^n. \tag{B.83}$$

The calculations from the 1D case can be repeated with the terms in the y and z directions. Collecting terms that fulfill the PDE, we end up with

$$\begin{split} R^{\mu}\_{i,j,k} &= \left[ \frac{1}{12} \boldsymbol{u}\_{\mathsf{c},III} \Delta t^2 - c^2 \frac{1}{12} \left( \boldsymbol{u}\_{\mathsf{c},\mathtt{x}\xxx} \Delta \boldsymbol{x}^2 + \boldsymbol{u}\_{\mathsf{c},\mathtt{y}\xxx} \Delta \boldsymbol{x}^2 + \boldsymbol{u}\_{\mathsf{c},\mathtt{z}\xxx} \Delta \boldsymbol{z}^2 \right) \right]\_{i,j,k}^{\mathsf{n}} \\ &\quad + \mathcal{O}(\Delta t^4, \Delta \boldsymbol{x}^4, \Delta \boldsymbol{y}^4, \Delta \boldsymbol{z}^4) . \end{split} \tag{\text{B.84}}$$

#### **B.6 Diffusion Equations**

#### **B.6.1 Linear Diffusion Equation in 1D**

The standard, linear, 1D diffusion equation takes the form

$$\frac{\partial u}{\partial t} = \alpha \frac{\partial^2 u}{\partial x^2} + f(x, t), \quad x \in (0, L), \ t \in (0, T], \tag{B.85}$$

where ˛>0 is a constant diffusion coefficient. A more compact form of the diffusion equation is ut <sup>D</sup> ˛uxx <sup>C</sup> <sup>f</sup> .

The spatial derivative in the diffusion equation, ˛uxx, is commonly discretized as ŒDxDxu<sup>n</sup> <sup>i</sup> . The time-derivative, however, can be treated by a variety of methods.

**The Forward Euler scheme in time** Let us start with the simple Forward Euler scheme:

$$[D\_l^+u = aD\_x D\_x u + f]\_i^n \, .$$

The truncation error arises as the residual R when inserting the exact solution ue in the discrete equations:

$$[D\_t^+u\_\mathfrak{c} = \alpha D\_x D\_x u\_\mathfrak{c} + f + R]\_i^n \dots$$

Now, using (B.11)–(B.12) and (B.17)–(B.18), we can transform the difference operators to derivatives:

$$\begin{split} &u\_{\mathsf{c},t}(\mathbf{x}\_{i},t\_{n}) + \frac{1}{2}u\_{\mathsf{c},t\_{l}}(t\_{n})\Delta t + \mathcal{O}(\Delta t^{2}) \\ &= \alpha u\_{\mathsf{c},\mathbf{x}\boldsymbol{\epsilon}}(\mathbf{x}\_{i},t\_{n}) + \frac{\alpha}{12}u\_{\mathsf{c},\mathbf{x}\boldsymbol{\epsilon}\boldsymbol{\times}\boldsymbol{x}}(\mathbf{x}\_{i},t\_{n})\Delta x^{2} + \mathcal{O}(\Delta x^{4}) + f(\mathbf{x}\_{i},t\_{n}) + R\_{i}^{n} \ . \end{split}$$

The terms <sup>u</sup>e;t.xi ; tn/˛ue;xx.xi ; tn/f .xi ; tn/ vanish because <sup>u</sup><sup>e</sup> solves the PDE. The truncation error then becomes

$$R\_i^n = \frac{1}{2} u\_{\mathfrak{e}, \iota t}(t\_n) \Delta t + \mathcal{O}(\Delta t^2) - \frac{\alpha}{12} u\_{\mathfrak{e}, \text{xxx}}(\chi\_i, t\_n) \Delta x^2 + \mathcal{O}(\Delta x^4) \dots$$

**The Crank-Nicolson scheme in time** The Crank-Nicolson method consists of using a centered difference for ut and an arithmetic average of the uxx term:

$$\left[D\_l u\right]\_l^{n+\frac{1}{2}} = \alpha \frac{1}{2} \left( [D\_x D\_x u]\_l^n + [D\_x D\_x u]\_l^{n+1} \right) + f\_l^{n+\frac{1}{2}}.$$

The equation for the truncation error is

$$\left[\left[D\_{l}u\_{\mathbf{c}}\right]\_{l}^{n+\frac{1}{2}}=\alpha\frac{1}{2}\left(\left[D\_{x}D\_{x}u\_{\mathbf{c}}\right]\_{l}^{n}+\left[D\_{x}D\_{x}u\_{\mathbf{c}}\right]\_{l}^{n+1}\right)+f\_{l}^{n+\frac{1}{2}}+R\_{l}^{n+\frac{1}{2}}\right.$$

To find the truncation error, we start by expressing the arithmetic average in terms of values at time tnC <sup>1</sup> 2 . According to (B.21)–(B.22),

$$\begin{split} \frac{1}{2} \left( [D\_{\boldsymbol{x}} D\_{\boldsymbol{x}} \boldsymbol{u}\_{\mathfrak{C}}]\_{i}^{n} + [D\_{\boldsymbol{x}} D\_{\boldsymbol{x}} \boldsymbol{u}\_{\mathfrak{C}}]\_{i}^{n+1} \right) &= \left[ D\_{\boldsymbol{x}} D\_{\boldsymbol{x}} \boldsymbol{u}\_{\mathfrak{C}} \right]\_{i}^{n + \frac{1}{2}} + \frac{1}{8} [D\_{\boldsymbol{x}} D\_{\boldsymbol{x}} \boldsymbol{u}\_{\mathfrak{C},\boldsymbol{f}}]\_{i}^{n + \frac{1}{2}} \Delta t^{2} \\ &+ \mathcal{O}(\Delta t^{4}) \,. \end{split}$$

With (B.17)–(B.18) we can express the difference operator DxDxu in terms of a derivative:

$$\left[D\_{\mathbf{x}}D\_{\mathbf{x}}u\_{\mathbf{c}}\right]^{n+\frac{1}{2}} = u\_{\mathbf{c},\mathbf{x}\boldsymbol{\chi}}\left(\mathbf{x}\_{i},t\_{n+\frac{1}{2}}\right) + \frac{1}{12}u\_{\mathbf{c},\mathbf{x}\boldsymbol{\chi}\boldsymbol{\chi}}\left(\mathbf{x}\_{i},t\_{n+\frac{1}{2}}\right)\Delta x^{2} + \mathcal{O}(\Delta x^{4})\dots$$

The error term from the arithmetic mean is similarly expanded,

$$\frac{1}{8} [D\_{\underline{x}} D\_{\underline{x}} u\_{\mathfrak{e}, \mathcal{U}}]\_{i}^{n+\frac{1}{2}} \Delta t^{2} = \frac{1}{8} u\_{\mathfrak{e}, \mathcal{U}\boldsymbol{\varepsilon}\boldsymbol{x}} \left(\boldsymbol{x}\_{i}, t\_{n+\frac{1}{2}}\right) \Delta t^{2} + \mathcal{O}(\Delta t^{2} \Delta \boldsymbol{x}^{2}) \dots$$

The time derivative is analyzed using (B.5)–(B.6):

$$\left[D\_{l}u\right]\_{i}^{n+\frac{1}{2}} = u\_{\mathfrak{C},l}\left(\mathbf{x}\_{i},t\_{n+\frac{1}{2}}\right) + \frac{1}{24}u\_{\mathfrak{C},III}\left(\mathbf{x}\_{i},t\_{n+\frac{1}{2}}\right)\Delta t^{2} + \mathcal{O}(\Delta t^{4})\dots$$

Summing up all the contributions and notifying that

$$
\mu\_{\mathfrak{C},l} \left( \mathbf{x}\_i, t\_{n+\frac{1}{2}} \right) = \alpha u\_{\mathfrak{C}, \times \mathbf{x}} \left( \mathbf{x}\_i, t\_{n+\frac{1}{2}} \right) + f \left( \mathbf{x}\_i, t\_{n+\frac{1}{2}} \right),
$$

the truncation error is given by

$$\begin{split} R\_{i}^{n+\frac{1}{2}} &= \frac{1}{8} u\_{\mathsf{C},\boldsymbol{\chi}\boldsymbol{x}} \left( \boldsymbol{x}\_{i}, t\_{n+\frac{1}{2}} \right) \Delta t^{2} + \frac{1}{12} u\_{\mathsf{C},\boldsymbol{\chi}\boldsymbol{x}\boldsymbol{\chi}\boldsymbol{x}} \left( \boldsymbol{x}\_{i}, t\_{n+\frac{1}{2}} \right) \Delta \boldsymbol{x}^{2} \\ &+ \frac{1}{24} u\_{\mathsf{C},\boldsymbol{\chi}\boldsymbol{\Pi}} \left( \boldsymbol{x}\_{i}, t\_{n+\frac{1}{2}} \right) \Delta t^{2} + \mathcal{O}(\Delta \boldsymbol{x}^{4}) + \mathcal{O}(\Delta t^{4}) + \mathcal{O}(\Delta t^{2} \Delta \boldsymbol{x}^{2}) \, . \end{split}$$

#### **B.6.2 Nonlinear Diffusion Equation in 1D**

We address the PDE

$$
\frac{\partial u}{\partial t} = \frac{\partial}{\partial x} \left( \alpha(u) \frac{\partial u}{\partial x} \right) + f(u),
$$

with two potentially nonlinear coefficients q.u/ and ˛.u/. We use a Backward Euler scheme with arithmetic mean for ˛.u/,

$$\left[D^-u = D\_\times \overline{\alpha(u)}^\times D\_\times u + f(u)\right]\_i^n \dots$$

Inserting ue defines the truncation error R:

$$\left[D^{-}u\_{\mathfrak{e}} = D\_{\mathfrak{x}}\overline{\alpha(u\_{\mathfrak{e}})}^{\mathfrak{x}}D\_{\mathfrak{x}}u\_{\mathfrak{e}} + f(u\_{\mathfrak{e}}) + R\right]\_i^n \dots$$

The most computationally challenging part is the variable coefficient with ˛.u/, but we can use the same setup as in Sect. B.5.3 and arrive at a truncation error *<sup>O</sup>*.x2/ for the x-derivative term. The nonlinear term Œf .ue/<sup>n</sup> <sup>i</sup> <sup>D</sup> f .ue.xi ; tn// matches x and t derivatives of ue in the PDE. We end up with

$$R\_i'' = -\frac{1}{2}\frac{\partial^2}{\partial t^2}u\_{\mathfrak{C}}(x\_i, t\_n)\Delta t + \mathcal{O}(\Delta x^2)\,.$$

#### **B.7 Exercises**

#### **Exercise B.1: Truncation error of a weighted mean**

Derive the truncation error of the weighted mean in (B.19)–(B.20).

*Hint* Expand u<sup>n</sup>C<sup>1</sup> <sup>e</sup> and u<sup>n</sup> <sup>e</sup> around tnC- . Filename: trunc\_weighted\_mean.

#### **Exercise B.2: Simulate the error of a weighted mean**

We consider the weighted mean

$$
\mu\_{\mathbf{c}}(t\_n) \approx \theta u\_{\mathbf{c}}^{n+1} + (1 - \theta) u\_{\mathbf{c}}^n \dots
$$

Choose some specific function for ue.t/ and compute the error in this approximation for a sequence of decreasing t <sup>D</sup> tnC<sup>1</sup> tn and for - D 0; 0:25; 0:5; 0:75; 1. Assuming that the error equals Ct <sup>r</sup> , for some constants C and r, compute r for the two smallest t values for each choice of and compare with the truncation error (B.19)–(B.20).

Filename: trunc\_theta\_avg.

#### **Exercise B.3: Verify a truncation error formula**

Set up a numerical experiment as explained in Sect. B.3.5 for verifying the formulas (B.15)–(B.16).

Filename: trunc\_backward\_2level.

#### **Problem B.4: Truncation error of the Backward Euler scheme**

Derive the truncation error of the Backward Euler scheme for the decay ODE <sup>u</sup><sup>0</sup> D au with constant <sup>a</sup>. Extend the analysis to cover the variable-coefficient case <sup>u</sup><sup>0</sup> D a.t/u C b.t/.

Filename: trunc\_decay\_BE.

#### **Exercise B.5: Empirical estimation of truncation errors**

Use the ideas and tools from Sect. B.3.5 to estimate the rate of the truncation error of the Backward Euler and Crank-Nicolson schemes applied to the exponential decay model <sup>u</sup><sup>0</sup> D au, u.0/ D <sup>I</sup> .

*Hint* In the Backward Euler scheme, the truncation error can be estimated at mesh points <sup>n</sup> D 1; : : : ; N , while the truncation error must be estimated at midpoints tnC 1 2 , <sup>n</sup> D 0; : : : ; N <sup>1</sup> for the Crank-Nicolson scheme. The truncation\_ error(dt, N) function to be supplied to the estimate function needs to carefully implement these details and return the right t array such that t[i] is the time point corresponding to the quantities R[i] and R\_a[i].

Filename: trunc\_decay\_BNCN.

#### **Exercise B.6: Correction term for a Backward Euler scheme**

Consider the model <sup>u</sup><sup>0</sup> D au, u.0/ D <sup>I</sup> . Use the ideas of Sect. B.3.6 to add a correction term to the ODE such that the Backward Euler scheme applied to the perturbed ODE problem is of second order in t. Find the amplification factor. Filename: trunc\_decay\_BE\_corr.

#### **Problem B.7: Verify the effect of correction terms**

Make a program that solves <sup>u</sup><sup>0</sup> D au, u.0/ D <sup>I</sup> , by the --rule and computes convergence rates. Adjust a such that it incorporates correction terms. Run the program to verify that the error from the Forward and Backward Euler schemes with perturbed a is *<sup>O</sup>*.t 2/, while the error arising from the Crank-Nicolson scheme with perturbed a is *<sup>O</sup>*.t 4/.

Filename: trunc\_decay\_corr\_verify.

#### **Problem B.8: Truncation error of the Crank-Nicolson scheme**

The variable-coefficient ODE <sup>u</sup><sup>0</sup> D a.t/uCb.t/ can be discretized in two different ways by the Crank-Nicolson scheme, depending on whether we use averages for a and b or compute them at the midpoint t nC 1 2 :

$$\mathbb{E}\left[D\_t u = -a\overline{u}' + b\right]^{n + \frac{1}{2}},\tag{B.86}$$

$$
\left[D\_l u = \overline{-au + b}^l\right]^{n + \frac{1}{2}}.\tag{B.87}
$$

Compute the truncation error in both cases. Filename: trunc\_decay\_CN\_vc.

# **Problem B.9: Truncation error of** u<sup>0</sup> <sup>D</sup> f .u; t/

Consider the general nonlinear first-order scalar ODE

$$u'(t) = f(u(t), t) \,.$$

Show that the truncation error in the Forward Euler scheme,

$$[D\_t^+u = f(u,t)]^n,$$

and in the Backward Euler scheme,

$$[D\_t^-u = f(u,t)]^n,$$

both are of first order, regardless of what f is.

Showing the order of the truncation error in the Crank-Nicolson scheme,

$$[D\_t u = f(u, t)]^{n + \frac{1}{2}},$$

is somewhat more involved: Taylor expand u<sup>n</sup> e, u<sup>n</sup>C<sup>1</sup> <sup>e</sup> , f .u<sup>n</sup> <sup>e</sup>; tn/, and f .u<sup>n</sup>C<sup>1</sup> <sup>e</sup> ; tnC1/ around tnC <sup>1</sup> 2 , and use that

$$\frac{df}{dt} = \frac{\partial f}{\partial u}u' + \frac{\partial f}{\partial t}.$$

Check that the derived truncation error is consistent with previous results for the case f .u; t/ D au. Filename: trunc\_nonlinear\_ODE.

#### **Exercise B.10: Truncation error of** ŒDtDtun

Derive the truncation error of the finite difference approximation (B.17)–(B.18) to the second-order derivative.

Filename: trunc\_d2u.

# **Exercise B.11: Investigate the impact of approximating** u<sup>0</sup>

.0/ Section B.4.1 describes two ways of discretizing the initial condition u<sup>0</sup> .0/ D <sup>V</sup> for a vibration model <sup>u</sup><sup>00</sup> C !2u D <sup>0</sup>: a centered difference ŒD2tu D V <sup>0</sup> or a forward difference ŒD<sup>C</sup> <sup>t</sup> <sup>u</sup> <sup>D</sup> V <sup>0</sup>. The program vib\_undamped.py solves <sup>u</sup><sup>00</sup> <sup>C</sup> !2u <sup>D</sup> <sup>0</sup> with ŒD2tu D 0<sup>0</sup> and features a function convergence\_rates for computing the order of the error in the numerical solution. Modify this program such that it applies the forward difference ŒD<sup>C</sup> <sup>t</sup> <sup>u</sup> <sup>D</sup> 0<sup>0</sup> and report how this simpler and more convenient approximation impacts the overall convergence rate of the scheme. Filename: trunc\_vib\_ic\_fw.

#### **Problem B.12: Investigate the accuracy of a simplified scheme**

Consider the ODE

$$
tau'' + \beta |u'|u' + s(u) = F(t) \, .
$$

The term ju<sup>0</sup> ju<sup>0</sup> quickly gives rise to nonlinearities and complicates the scheme. Why not simply apply a backward difference to this term such that it only involves known values? That is, we propose to solve

$$[m D\_l D\_l u + \beta | D\_l^- u | D\_l^- u + s(u) = F]^n \dots$$

Drop the absolute value for simplicity and find the truncation error of the scheme. Perform numerical experiments with the scheme and compared with the one based on centered differences. Can you illustrate the accuracy loss visually in real computations, or is the asymptotic analysis here mainly of theoretical interest? Filename: trunc\_vib\_bw\_damping.

# **C Software Engineering; Wave Equation Model**

### **C.1 A 1D Wave Equation Simulator**

#### **C.1.1 Mathematical Model**

Let ut , utt , ux, uxx denote derivatives of u with respect to the subscript, i.e., utt is a second-order time derivative and ux is a first-order space derivative. The initialboundary value problem implemented in the wave1D\_dn\_vc.py code is


We allow variable wave velocity <sup>c</sup>2.x/ D q.x/, and Dirichlet or homogeneous Neumann conditions at the boundaries.

#### **C.1.2 Numerical Discretization**

The PDE is discretized by second-order finite differences in time and space, with arithmetic mean for the variable coefficient

$$\mathbb{E}\left[D\_l D\_l u = D\_\chi \overline{q}^\chi D\_\chi u + f\right]\_l^n. \tag{C.6}$$

The Neumann boundary conditions are discretized by

$$[[D\_{2x}u]\_i^n = 0,$$

at a boundary point i. The details of how the numerical scheme is worked out are described in Sect. 2.6 and 2.7.

#### **C.1.3 A Solver Function**

The general initial-boundary value problem (C.1)–(C.5) solved by finite difference methods can be implemented as shown in the following solver function (taken from the file wave1D\_dn\_vc.py). This function builds on simpler versions described in Sect. 2.3, 2.4 2.6, and 2.7. There are several quite advanced constructs that will be commented upon later. The code is lengthy, but that is because we provide a lot of flexibility with respect to input arguments, boundary conditions, and optimization (scalar versus vectorized loops).

```
def solver(
    I, V, f, c, U_0, U_L, L, dt, C, T,
    user_action=None, version='scalar',
    stability_safety_factor=1.0):
    """Solve u_tt=(c^2*u_x)_x + f on (0,L)x(0,T]."""
    # --- Compute time and space mesh ---
    Nt = int(round(T/dt))
    t = np.linspace(0, Nt*dt, Nt+1) # Mesh points in time
    # Find max(c) using a fake mesh and adapt dx to C and dt
    if isinstance(c, (float,int)):
        c_max = c
    elif callable(c):
        c_max = max([c(x_) for x_ in np.linspace(0, L, 101)])
    dx = dt*c_max/(stability_safety_factor*C)
    Nx = int(round(L/dx))
    x = np.linspace(0, L, Nx+1) # Mesh points in space
    # Make sure dx and dt are compatible with x and t
    dx = x[1] - x[0]
    dt = t[1] - t[0]
    # Make c(x) available as array
    if isinstance(c, (float,int)):
        c = np.zeros(x.shape) + c
    elif callable(c):
        # Call c(x) and fill array c
        c_ = np.zeros(x.shape)
        for i in range(Nx+1):
            c_[i] = c(x[i])
        c = c_
    q = c**2
    C2 = (dt/dx)**2; dt2 = dt*dt # Help variables in the scheme
    # --- Wrap user-given f, I, V, U_0, U_L if None or 0 ---
    if f is None or f == 0:
        f = (lambda x, t: 0) if version == 'scalar' else \
            lambda x, t: np.zeros(x.shape)
    if I is None or I == 0:
        I = (lambda x: 0) if version == 'scalar' else \
            lambda x: np.zeros(x.shape)
    if V is None or V == 0:
        V = (lambda x: 0) if version == 'scalar' else \
            lambda x: np.zeros(x.shape)
```

```
if U_0 is not None:
    if isinstance(U_0, (float,int)) and U_0 == 0:
        U_0 = lambda t: 0
if U_L is not None:
    if isinstance(U_L, (float,int)) and U_L == 0:
        U_L = lambda t: 0
# --- Make hash of all input data ---
import hashlib, inspect
data = inspect.getsource(I) + '_' + inspect.getsource(V)+\
       '_' + inspect.getsource(f) + '_' + str(c) + '_' + \
       ('None' if U_0 is None else inspect.getsource(U_0)) + \
       ('None' if U_L is None else inspect.getsource(U_L)) + \
       '_' + str(L) + str(dt) + '_' + str(C) + '_' + str(T)+\
       '_' + str(stability_safety_factor)
hashed_input = hashlib.sha1(data).hexdigest()
if os.path.isfile('.' + hashed_input + '_archive.npz'):
    # Simulation is already run
    return -1, hashed_input
# --- Allocate memomry for solutions ---
u = np.zeros(Nx+1) # Solution array at new time level
u_n = np.zeros(Nx+1) # Solution at 1 time level back
u_nm1 = np.zeros(Nx+1) # Solution at 2 time levels back
import time; t0 = time.clock() # CPU time measurement
# --- Valid indices for space and time mesh ---
Ix = range(0, Nx+1)
It = range(0, Nt+1)
# --- Load initial condition into u_n ---
for i in range(0,Nx+1):
    u_n[i] = I(x[i])
if user_action is not None:
    user_action(u_n, x, t, 0)
# --- Special formula for the first step ---
for i in Ix[1:-1]:
    u[i] = u_n[i] + dt*V(x[i]) + \
    0.5*C2*(0.5*(q[i] + q[i+1])*(u_n[i+1] - u_n[i]) - \
            0.5*(q[i] + q[i-1])*(u_n[i] - u_n[i-1])) + \
    0.5*dt2*f(x[i], t[0])
i = Ix[0]
if U_0 is None:
    # Set boundary values (x=0: i-1 -> i+1 since u[i-1]=u[i+1]
    # when du/dn = 0, on x=L: i+1 -> i-1 since u[i+1]=u[i-1])
    ip1 = i+1
    im1 = ip1 # i-1 -> i+1
    u[i] = u_n[i] + dt*V(x[i]) + \
           0.5*C2*(0.5*(q[i] + q[ip1])*(u_n[ip1] - u_n[i]) - \
                   0.5*(q[i] + q[im1])*(u_n[i] - u_n[im1])) + \
    0.5*dt2*f(x[i], t[0])
else:
    u[i] = U_0(dt)
```

```
i = Ix[-1]
if U_L is None:
    im1 = i-1
    ip1 = im1 # i+1 -> i-1
    u[i] = u_n[i] + dt*V(x[i]) + \
           0.5*C2*(0.5*(q[i] + q[ip1])*(u_n[ip1] - u_n[i]) - \
                   0.5*(q[i] + q[im1])*(u_n[i] - u_n[im1])) + \
    0.5*dt2*f(x[i], t[0])
else:
    u[i] = U_L(dt)
if user_action is not None:
    user_action(u, x, t, 1)
# Update data structures for next step
#u_nm1[:] = u_n; u_n[:] = u # safe, but slower
u_nm1, u_n, u = u_n, u, u_nm1
# --- Time loop ---
for n in It[1:-1]:
    # Update all inner points
    if version == 'scalar':
        for i in Ix[1:-1]:
            u[i]=- u_nm1[i] + 2*u_n[i]+\
                C2*(0.5*(q[i] + q[i+1])*(u_n[i+1] - u_n[i]) - \
                    0.5*(q[i] + q[i-1])*(u_n[i] - u_n[i-1])) + \
            dt2*f(x[i], t[n])
    elif version == 'vectorized':
        u[1:-1] = - u_nm1[1:-1] + 2*u_n[1:-1] + \
        C2*(0.5*(q[1:-1] + q[2:])*(u_n[2:] - u_n[1:-1]) -
            0.5*(q[1:-1] + q[:-2])*(u_n[1:-1] - u_n[:-2])) + \
        dt2*f(x[1:-1], t[n])
    else:
        raise ValueError('version=%s' % version)
    # Insert boundary conditions
    i = Ix[0]
    if U_0 is None:
        # Set boundary values
        # x=0: i-1 -> i+1 since u[i-1]=u[i+1] when du/dn=0
        # x=L: i+1 -> i-1 since u[i+1]=u[i-1] when du/dn=0
        ip1 = i+1
        im1 = ip1
        u[i]=- u_nm1[i] + 2*u_n[i]+\
               C2*(0.5*(q[i] + q[ip1])*(u_n[ip1] - u_n[i]) - \
                   0.5*(q[i] + q[im1])*(u_n[i] - u_n[im1])) + \
        dt2*f(x[i], t[n])
    else:
        u[i] = U_0(t[n+1])
    i = Ix[-1]
    if U_L is None:
        im1 = i-1
        ip1 = im1
```

```
u[i]=- u_nm1[i] + 2*u_n[i]+\
               C2*(0.5*(q[i] + q[ip1])*(u_n[ip1] - u_n[i]) - \
                   0.5*(q[i] + q[im1])*(u_n[i] - u_n[im1])) + \
        dt2*f(x[i], t[n])
    else:
        u[i] = U_L(t[n+1])
    if user_action is not None:
        if user_action(u, x, t, n+1):
            break
    # Update data structures for next step
    u_nm1, u_n, u = u_n, u, u_nm1
cpu_time = time.clock() - t0
return cpu_time, hashed_input
```
### **C.2 Saving Large Arrays in Files**

Numerical simulations produce large arrays as results and the software needs to store these arrays on disk. Several methods are available in Python. We recommend to use tailored solutions for large arrays and not standard file storage tools such as pickle (cPickle for speed in Python version 2) and shelve, because the tailored solutions have been optimized for array data and are hence much faster than the standard tools.

### **C.2.1 Using** savez **to Store Arrays in Files**

**Storing individual arrays** The numpy.storez function can store a set of arrays to a named file in a zip archive. An associated function numpy.load can be used to read the file later. Basically, we call numpy.storez(filename, \*\*kwargs), where kwargs is a dictionary containing array names as keys and the corresponding array objects as values. Very often, the solution at a time point is given a natural name where the name of the variable and the time level counter are combined, e.g., u11 or v39. Suppose n is the time level counter and we have two solution arrays, u and v, that we want to save to a zip archive. The appropriate code is

```
import numpy as np
u_name = 'u%04d' % n # array name
v_name = 'v%04d' % n # array name
kwargs = {u_name: u, v_name: v} # keyword args for savez
fname = '.mydata%04d.dat' % n
np.savez(fname, **kwargs)
if n == 0: # store x once
   np.savez('.mydata_x.dat', x=x)
```
Since the name of the array must be given as a keyword argument to savez, and the name must be constructed as shown, it becomes a little tricky to do the call, but with a dictionary kwargs and \*\*kwargs, which sends each key-value pair as individual keyword arguments, the task gets accomplished.

**Merging zip archives** Each separate call to np.savez creates a new file (zip archive) with extension .npz. It is very convenient to collect all results in one archive instead. This can be done by merging all the individual .npz files into a single zip archive:

```
def merge_zip_archives(individual_archives, archive_name):
    """
    Merge individual zip archives made with numpy.savez into
    one archive with name archive_name.
    The individual archives can be given as a list of names
    or as a Unix wild chard filename expression for glob.glob.
    The result of this function is that all the individual
    archives are deleted and the new single archive made.
    """
    import zipfile
    archive = zipfile.ZipFile(
        archive_name, 'w', zipfile.ZIP_DEFLATED,
        allowZip64=True)
    if isinstance(individual_archives, (list,tuple)):
        filenames = individual_archives
    elif isinstance(individual_archives, str):
        filenames = glob.glob(individual_archives)
    # Open each archive and write to the common archive
    for filename in filenames:
        f = zipfile.ZipFile(filename, 'r',
                            zipfile.ZIP_DEFLATED)
        for name in f.namelist():
            data = f.open(name, 'r')
            # Save under name without .npy
            archive.writestr(name[:-4], data.read())
        f.close()
        os.remove(filename)
    archive.close()
```
Here we remark that savez automatically adds the .npz extension to the names of the arrays we store. We do not want this extension in the final archive.

**Reading arrays from zip archives** Archives created by savez or the merged archive we describe above with name of the form myarchive.npz, can be conveniently read by the numpy.load function:

```
import numpy as np
array_names = np.load('myarchive.npz')
for array_name in array_names:
    # array_names[array_name] is the array itself
    # e.g. plot(array_names['t'], array_names[array_name])
```
### **C.2.2 Using** joblib **to Store Arrays in Files**

The Python package joblib has nice functionality for efficient storage of arrays on disk. The following class applies this functionality so that one can save an array, or in fact any Python data structure (e.g., a dictionary of arrays), to disk under a certain name. Later, we can retrieve the object by use of its name. The name of the directory under which the arrays are stored by joblib can be given by the user.

```
class Storage(object):
    """
    Store large data structures (e.g. numpy arrays) efficiently
    using joblib.
    Use:
    >>> from Storage import Storage
    >>> storage = Storage(cachedir='tmp_u01', verbose=1)
    >>> import numpy as np
    >>> a = np.linspace(0, 1, 100000) # large array
    >>> b = np.linspace(0, 1, 100000) # large array
    >>> storage.save('a', a)
    >>> storage.save('b', b)
    >>> # later
    >>> a = storage.retrieve('a')
    >>> b = storage.retrieve('b')
    """
    def __init__(self, cachedir='tmp', verbose=1):
        """
        Parameters
        ----------
        cachedir: str
             Name of directory where objects are stored in files.
        verbose: bool, int
             Let joblib and this class speak when storing files
             to disk.
        """
        import joblib
        self.memory = joblib.Memory(cachedir=cachedir,
                                     verbose=verbose)
        self.verbose = verbose
        self.retrieve = self.memory.cache(
            self.retrieve, ignore=['data'])
        self.save = self.retrieve
    def retrieve(self, name, data=None):
        if self.verbose > 0:
            print 'joblib save of', name
        return data
```
The retrive and save functions, which do the work, seem quite magic. The idea is that joblib looks at the name parameter and saves the return value data to disk if the name parameter has not been used in a previous call. Otherwise, if name is already registered, joblib fetches the data object from file and returns it (this is an example of a memoize function, see Section 2.1.4 in [11] for a brief explanation]).

#### **C.2.3 Using a Hash to Create a File or Directory Name**

Array storage techniques like those outlined in Sect. C.2.2 and C.2.1 demand the user to assign a name for the file(s) or directory where the solution is to be stored. Ideally, this name should reflect parameters in the problem such that one can recognize an already run simulation. One technique is to make a hash string out of the input data. A hash string is a 40-character long hexadecimal string that uniquely reflects another potentially much longer string. (You may be used to hash strings from the Git version control system: every committed version of the files in Git is recognized by a hash string.)

Suppose you have some input data in the form of functions, numpy arrays, and other objects. To turn these input data into a string, we may grab the source code of the functions, use a very efficient hash method for potentially large arrays, and simply convert all other objects via str to a string representation. The final string, merging all input data, is then converted to an SHA1 hash string such that we represent the input with a 40-character long string.

```
def myfunction(func1, func2, array1, array2, obj1, obj2):
    # Convert arguments to hash
    import inspect, joblib, hashlib
    data = (inspect.getsource(func1),
            inspect.getsource(func2),
        joblib.hash(array1),
        joblib.hash(array2),
        str(obj1),
        str(obj2))
    hash_input = hashlib.sha1(data).hexdigest()
```
It is wise to use joblib.hash and not try to do a str(array1), since that string can be *very* long, and joblib.hash is more efficient than hashlib when turning these data into a hash.

#### **Remark: turning function objects into their source code is unreliable!**

The idea of turning a function object into a string via its source code may look smart, but is not a completely reliable solution. Suppose we have some function

```
x0 = 0.1
f = lambda x: 0 if x <= x0 else 1
```
The source code will be f = lambda x: 0 if x <= x0 else 1, so if the calling code changes the value of x0 (which f remembers - it is a closure), the source remains unchanged, the hash is the same, and the change in input data is unnoticed. Consequently, the technique above must be used with care. The user can always just remove the stored files in disk and thereby force a recomputation (provided the software applies a hash to test if a zip archive or joblib subdirectory exists, and if so, avoids recomputation).

### **C.3 Software for the 1D Wave Equation**

We use numpy.storez to store the solution at each time level on disk. Such actions must be taken care of outside the solver function, more precisely in the user\_action function that is called at every time level.

We have, in the wave1D\_dn\_vc.py code, implemented the user\_action callback function as a class PlotAndStoreSolutionwith a \_\_call\_\_(self, x, t, t, n) method for the user\_action function. Basically, \_\_call\_\_ stores and plots the solution. The storage makes use of the numpy.savez function for saving a set of arrays to a zip archive. Here, in this callback function, we want to save one array, u. Since there will be many such arrays, we introduce the array names 'u%04d' % n and closely related filenames. The usage of numpy.savez in \_\_call\_\_ goes like this:

```
from numpy import savez
name = 'u%04d' % n # array name
kwargs = {name: u} # keyword args for savez
fname = '.' + self.filename + '_' + name + '.dat'
self.t.append(t[n]) # store corresponding time value
savez(fname, **kwargs)
if n == 0: # store x once
   savez('.' + self.filename + '_x.dat', x=x)
```
For example, if n is 10 and self.filename is tmp, the above call to savez becomes savez('.tmp\_u0010.dat', u0010=u). The actual filename becomes .tmp\_u0010.dat.npz. The actual array name becomes u0010.npy.

Each savez call results in a file, so after the simulation we have one file per time level. Each file produced by savez is a zip archive. It makes sense to merge all the files into one. This is done in the close\_file method in the PlotAndStoreSolution class. The code goes as follows.

```
class PlotAndStoreSolution:
    ...
    def close_file(self, hashed_input):
        """
        Merge all files from savez calls into one archive.
        hashed_input is a string reflecting input data
        for this simulation (made by solver).
        """
        if self.filename is not None:
            # Save all the time points where solutions are saved
            savez('.' + self.filename + '_t.dat',
                  t=array(self.t, dtype=float))
            # Merge all savez files to one zip archive
            archive_name = '.' + hashed_input + '_archive.npz'
            filenames = glob.glob('.' + self.filename + '*.dat.npz')
            merge_zip_archives(filenames, archive_name)
```
We use various ZipFile functionality to extract the content of the individual files (each with name filename) and write it to the merged archive (archive). There is only one array in each individual file (filename) so strictly speaking, there is no need for the loop for name in f.namelist() (as f.namelist() returns a list of length 1). However, in other applications where we compute more arrays at each time level, savez will store all these and then there is need for iterating over f.namelist().

Instead of merging the archives written by savez we could make an alternative implementation that writes all our arrays into one archive. This is the subject of Exercise C.2.

#### **C.3.1 Making Hash Strings from Input Data**

The hashed\_input argument, used to name the resulting archive file with all solutions, is supposed to be a hash reflecting all import parameters in the problem such that this simulation has a unique name. The hashed\_input string is made in the solver function, using the hashlib and inspect modules, based on the arguments to solver:

```
# Make hash of all input data
import hashlib, inspect
data = inspect.getsource(I) + '_' + inspect.getsource(V)+\
       '_' + inspect.getsource(f) + '_' + str(c) + '_' + \
       ('None' if U_0 is None else inspect.getsource(U_0)) + \
       ('None' if U_L is None else inspect.getsource(U_L)) + \
       '_' + str(L) + str(dt) + '_' + str(C) + '_' + str(T)+\
       '_' + str(stability_safety_factor)
hashed_input = hashlib.sha1(data).hexdigest()
```
To get the source code of a function f as a string, we use inspect.getsource(f). All input, functions as well as variables, is then merged to a string data, and then hashlib.sha1 makes a unique, much shorter (40 characters long), fixed-length string out of data that we can use in the archive filename.

#### **Remark**

Note that the construction of the data string is not fool proof: if, e.g., I is a formula with parameters and the parameters change, the source code is still the same and data and hence the hash remains unaltered. The implementation must therefore be used with care!

#### **C.3.2 Avoiding Rerunning Previously Run Cases**

If the archive file whose name is based on hashed\_input already exists, the simulation with the current set of parameters has been done before and one can avoid redoing the work. The solver function returns the CPU time and hashed\_input, and a negative CPU time means that no simulation was run. In that case we should not call the close\_file method above (otherwise we overwrite the archive with just the self.t array). The typical usage goes like

```
action = PlotAndStoreSolution(...)
dt = (L/Nx)/C # choose the stability limit with given Nx
cpu, hashed_input = solver(
    I=lambda x: ...,
    V=0, f=0, c=1, U_0=lambda t: 0, U_L=None, L=1,
    dt=dt, C=C, T=T,
    user_action=action, version='vectorized',
    stability_safety_factor=1)
action.make_movie_file()
if cpu > 0: # did we generate new data?
    action.close_file(hashed_input)
```
#### **C.3.3 Verification**

**Vanishing approximation error** Exact solutions of the numerical equations are always attractive for verification purposes since the software should reproduce such solutions to machine precision. With Dirichlet boundary conditions we can construct a function that is linear in t and quadratic in x that is also an exact solution of the scheme, while with Neumann conditions we are left with testing just a constant solution (see comments in Sect. 2.6.5).

**Convergence rates** A more general method for verification is to check the convergence rates. We must introduce one discretization parameter h and assume an error model <sup>E</sup> D C hr , where <sup>C</sup> and <sup>r</sup> are constants to be determine (i.e., <sup>r</sup> is the rate that we are interested in). Given two experiments with different resolutions hi and hi1, we can estimate <sup>r</sup> by

$$r = \frac{\ln(E\_i/E\_{i-1})}{\ln(h\_i/h\_{i-1})},$$

where Ei is the error corresponding to hi and Ei<sup>1</sup> corresponds to hi<sup>1</sup>. Section 2.2.2 explains the details of this type of verification and how we introduce the single discretization parameter <sup>h</sup> D t D Oct, for some constant <sup>c</sup>O. To compute the error, we had to rely on a global variable in the user action function. Below is an implementation where we have a more elegant solution in terms of a class: the error variable is not a class attribute and there is no need for a global error (which is always considered an advantage).

```
def convergence_rates(
    u_exact,
    I, V, f, c, U_0, U_L, L,
    dt0, num_meshes,
    C, T, version='scalar',
    stability_safety_factor=1.0):
    """
    Half the time step and estimate convergence rates for
    for num_meshes simulations.
    """
    class ComputeError:
        def __init__(self, norm_type):
            self.error = 0
```

```
def __call__(self, u, x, t, n):
        """Store norm of the error in self.E."""
        error = np.abs(u - u_exact(x, t[n])).max()
        self.error = max(self.error, error)
E = []
h = [] # dt, solver adjusts dx such that C=dt*c/dx
dt = dt0
for i in range(num_meshes):
    error_calculator = ComputeError('Linf')
    solver(I, V, f, c, U_0, U_L, L, dt, C, T,
           user_action=error_calculator,
           version='scalar',
           stability_safety_factor=1.0)
    E.append(error_calculator.error)
    h.append(dt)
    dt /= 2 # halve the time step for next simulation
print 'E:', E
print 'h:', h
r = [np.log(E[i]/E[i-1])/np.log(h[i]/h[i-1])
     for i in range(1,num_meshes)]
return r
```
The returned sequence r should converge to 2 since the error analysis in Sect. 2.10 predicts various error measures to behave like *<sup>O</sup>*.t 2/ C *<sup>O</sup>*.x2/. We can easily run the case with standing waves and the analytical solution u.x; t/ D cos. 2 <sup>L</sup> t/sin. 2 <sup>L</sup> x/. The call will be very similar to the one provided in the test\_convrate\_sincos function in Sect. 2.3.4, see the file wave1D\_dn\_vc.py for details.

### **C.4 Programming the Solver with Classes**

Many who know about class programming prefer to organize their software in terms of classes. This gives a richer application programming interface (API) since a function solver must have all its input data in terms of arguments, while a class-based solver naturally has a mix of method arguments and user-supplied methods. (Well, to be more precise, our solvers have demanded user\_action to be a function provided by the user, so it is possible to mix variables and functions in the input also with a solver function.)

We will next illustrate how some of the functionality in wave1D\_dn\_vc.py may be implemented by using classes. Focusing on class implementation aspects, we restrict the example case to a simpler wave with constant wave speed c. Applying the method of manufactured solutions, we test whether the class based implementation is able to compute the known exact solution within machine precision.

We will create a class Problem to hold the physical parameters of the problem and a class Solver to hold the numerical solution parameters besides the solver function itself. As the number of parameters increases, so does the amount of repetitive code. We therefore take the opportunity to illustrate how this may be counteracted by introducing a super class Parameters that allows code to be parameterized. In addition, it is convenient to collect the arrays that describe the mesh in a special Mesh class and make a class Function for a mesh function (mesh point values and its mesh). All the following code is found in wave1D\_oo.py.

#### **C.4.1 Class Parameters**

The classes Problem and Solver both inherit class Parameters, which handles reading of parameters from the command line and has methods for setting and getting parameter values. Since processing dictionaries is easier than processing a collection of individual attributes, the class Parameters requires each class Problem and Solver to represent their parameters by dictionaries, one compulsory and two optional ones. The compulsory dictionary, self.prm, contains all parameters, while a second and optional dictionary, self.type, holds the associated object types, and a third and optional dictionary, self.help, stores help strings. The Parameters class may be implemented as follows:

```
class Parameters(object):
    def __init__(self):
        """
        Subclasses must initialize self.prm with
        parameters and default values, self.type with
        the corresponding types, and self.help with
        the corresponding descriptions of parameters.
        self.type and self.help are optional, but
        self.prms must be complete and contain all parameters.
        """
        pass
    def ok(self):
        """Check if attr. prm, type, and help are defined."""
        if hasattr(self, 'prm') and \
           isinstance(self.prm, dict) and \
           hasattr(self, 'type') and \
           isinstance(self.type, dict) and \
           hasattr(self, 'help') and \
           isinstance(self.help, dict):
            return True
        else:
            raise ValueError(
                'The constructor in class %s does not '\
                'initialize the\ndictionaries '\
                'self.prm, self.type, self.help!' %
                self.__class__.__name__)
    def _illegal_parameter(self, name):
        """Raise exception about illegal parameter name."""
        raise ValueError(
            'parameter "%s" is not registered.\nLegal '\
            'parameters are\n%s' %
            (name, ' '.join(list(self.prm.keys()))))
```

```
def set(self, **parameters):
    """Set one or more parameters."""
    for name in parameters:
        if name in self.prm:
            self.prm[name] = parameters[name]
        else:
            self._illegal_parameter(name)
def get(self, name):
    """Get one or more parameter values."""
    if isinstance(name, (list,tuple)): # get many?
        for n in name:
            if n not in self.prm:
                self._illegal_parameter(name)
        return [self.prm[n] for n in name]
    else:
        if name not in self.prm:
            self._illegal_parameter(name)
        return self.prm[name]
def __getitem__(self, name):
    """Allow obj[name] indexing to look up a parameter."""
    return self.get(name)
def __setitem__(self, name, value):
    """
    Allow obj[name] = value syntax to assign a parameter's value.
    """
    return self.set(name=value)
def define_command_line_options(self, parser=None):
    self.ok()
    if parser is None:
        import argparse
        parser = argparse.ArgumentParser()
    for name in self.prm:
        tp = self.type[name] if name in self.type else str
        help = self.help[name] if name in self.help else None
        parser.add_argument(
            '--' + name, default=self.get(name), metavar=name,
            type=tp, help=help)
    return parser
def init_from_command_line(self, args):
    for name in self.prm:
        self.prm[name] = getattr(args, name)
```
#### **C.4.2 Class Problem**

Inheriting the Parameters class, our class Problem is defined as:

```
class Problem(Parameters):
    """
    Physical parameters for the wave equation
    u_tt = (c**2*u_x)_x + f(x,t) with t in [0,T] and
    x in (0,L). The problem definition is implied by
    the method of manufactured solution, choosing
    u(x,t)=x(L-x)(1+t/2) as our solution. This solution
    should be exactly reproduced when c is const.
    """
    def __init__(self):
        self.prm = dict(L=2.5, c=1.5, T=18)
        self.type = dict(L=float, c=float, T=float)
        self.help = dict(L='1D domain',
                         c='coefficient (wave velocity) in PDE',
                         T='end time of simulation')
    def u_exact(self, x, t):
        L = self['L']
        return x*(L-x)*(1+0.5*t)
    def I(self, x):
        return self.u_exact(x, 0)
    def V(self, x):
        return 0.5*self.u_exact(x, 0)
    def f(self, x, t):
        c = self['c']
        return 2*(1+0.5*t)*c**2
    def U_0(self, t):
        return self.u_exact(0, t)
    U_L = None
```
### **C.4.3 Class Mesh**

The Mesh class can be made valid for a space-time mesh in any number of space dimensions. To make the class versatile, the constructor accepts either a tuple/list of number of cells in each spatial dimension or a tuple/list of cell spacings. In addition, we need the size of the hypercube mesh as a tuple/list of 2-tuples with lower and upper limits of the mesh coordinates in each direction. For 1D meshes it is more natural to just write the number of cells or the cell size and not wrap it in a list. We also need the time interval from t0 to T. Giving no spatial discretization information implies a time mesh only, and vice versa. The Mesh class with documentation and a doc test should now be self-explanatory:

```
import numpy as np
class Mesh(object):
   """
   Holds data structures for a uniform mesh on a hypercube in
   space, plus a uniform mesh in time.
   ======== ==================================================
   Argument Explanation
   ======== ==================================================
   L List of 2-lists of min and max coordinates
             in each spatial direction.
   T Final time in time mesh.
   Nt Number of cells in time mesh.
   dt Time step. Either Nt or dt must be given.
   N List of number of cells in the spatial directions.
   d List of cell sizes in the spatial directions.
             Either N or d must be given.
   ======== ==================================================
   Users can access all the parameters mentioned above, plus
   ''x[i]'' and ''t'' for the coordinates in direction ''i''
   and the time coordinates, respectively.
   Examples:
   >>> from UniformFDMesh import Mesh
   >>>
   >>> # Simple space mesh
   >>> m = Mesh(L=[0,1], N=4)
   >>> print m.dump()
   space: [0,1] N=4 d=0.25
   >>>
   >>> # Simple time mesh
   >>> m = Mesh(T=4, dt=0.5)
   >>> print m.dump()
   time: [0,4] Nt=8 dt=0.5
   >>>
   >>> # 2D space mesh
   >>> m = Mesh(L=[[0,1], [-1,1]], d=[0.5, 1])
   >>> print m.dump()
   space: [0,1]x[-1,1] N=2x2 d=0.5,1
   >>>
   >>> # 2D space mesh and time mesh
   >>> m = Mesh(L=[[0,1], [-1,1]], d=[0.5, 1], Nt=10, T=3)
   >>> print m.dump()
   space: [0,1]x[-1,1] N=2x2 d=0.5,1 time: [0,3] Nt=10 dt=0.3
   """
   def __init__(self,
                L=None, T=None, t0=0,
                N=None, d=None,
                Nt=None, dt=None):
       if N is None and d is None:
            # No spatial mesh
            if Nt is None and dt is None:
               raise ValueError(
               'Mesh constructor: either Nt or dt must be given')
            if T is None:
               raise ValueError(
               'Mesh constructor: T must be given')
```

```
if Nt is None and dt is None:
    if N is None and d is None:
        raise ValueError(
        'Mesh constructor: either N or d must be given')
    if L is None:
        raise ValueError(
        'Mesh constructor: L must be given')
# Allow 1D interface without nested lists with one element
if L is not None and isinstance(L[0], (float,int)):
    # Only an interval was given
    L = [L]
if N is not None and isinstance(N, (float,int)):
    N = [N]
if d is not None and isinstance(d, (float,int)):
    d = [d]
# Set all attributes to None
self.x = None
self.t = None
self.Nt = None
self.dt = None
self.N = None
self.d = None
self.t0 = t0
if N is None and d is not None and L is not None:
    self.L = L
    if len(d) != len(L):
        raise ValueError(
            'd has different size (no of space dim.) from '
            'L: %d vs %d', len(d), len(L))
    self.d = d
    self.N = [int(round(float(self.L[i][1] -
                               self.L[i][0])/d[i]))
              for i in range(len(d))]
if d is None and N is not None and L is not None:
    self.L = L
    if len(N) != len(L):
        raise ValueError(
            'N has different size (no of space dim.) from '
            'L: %d vs %d', len(N), len(L))
    self.N = N
    self.d = [float(self.L[i][1] - self.L[i][0])/N[i]
              for i in range(len(N))]
if Nt is None and dt is not None and T is not None:
    self.T = T
    self.dt = dt
    self.Nt = int(round(T/dt))
if dt is None and Nt is not None and T is not None:
    self.T = T
    self.Nt = Nt
    self.dt = T/float(Nt)
if self.N is not None:
    self.x = [np.linspace(
              self.L[i][0], self.L[i][1], self.N[i]+1)
              for i in range(len(self.L))]
if Nt is not None:
    self.t = np.linspace(self.t0, self.T, self.Nt+1)
```

```
def get_num_space_dim(self):
    return len(self.d) if self.d is not None else 0
def has_space(self):
    return self.d is not None
def has_time(self):
    return self.dt is not None
def dump(self):
    s = ''
    if self.has_space():
        s += 'space: ' + \
             'x'.join(['[%g,%g]' % (self.L[i][0], self.L[i][1])
                      for i in range(len(self.L))]) + ' N='
        s += 'x'.join([str(Ni) for Ni in self.N]) + ' d='
        s += ','.join([str(di) for di in self.d])
    if self.has_space() and self.has_time():
        s += ' '
    if self.has_time():
        s += 'time: ' + '[%g,%g]' % (self.t0, self.T)+\
             ' Nt=%g' % self.Nt + ' dt=%g' % self.dt
    return s
```
#### **We rely on attribute access – not get/set functions!**

Java programmers, in particular, are used to get/set functions in classes to access internal data. In Python, we usually apply direct access of the attribute, such as m.N[i] if m is a Mesh object. A widely used convention is to do this as long as access to an attribute does not require additional code. In that case, one applies a property construction. The original interface remains the same after a property is introduced (in contrast to Java), so user will not notice a change to properties.

The only argument against direct attribute access in class Mesh is that the attributes are read-only so we could avoid offering a set function. Instead, we rely on the user that she does not assign new values to the attributes.

#### **C.4.4 Class Function**

A class Function is handy to hold a mesh and corresponding values for a scalar or vector function over the mesh. Since we may have a time or space mesh, or a combined time and space mesh, with one or more components in the function, some if tests are needed for allocating the right array sizes. To help the user, an indices attribute with the name of the indices in the final array u for the function values is made. The examples in the doc string should explain the functionality.

```
class Function(object):
    """
    A scalar or vector function over a mesh (of class Mesh).
    ========== ===================================================
    Argument Explanation
    ========== ===================================================
    mesh Class Mesh object: spatial and/or temporal mesh.
    num_comp Number of components in function (1 for scalar).
    space_only True if the function is defined on the space mesh
               only (to save space). False if function has values
               in space and time.
    ========== ===================================================
    The indexing of ''u'', which holds the mesh point values of the
    function, depends on whether we have a space and/or time mesh.
    Examples:
    >>> from UniformFDMesh import Mesh, Function
    >>>
    >>> # Simple space mesh
    >>> m = Mesh(L=[0,1], N=4)
    >>> print m.dump()
    space: [0,1] N=4 d=0.25
    >>> f = Function(m)
    >>> f.indices
    ['x0']
    >>> f.u.shape
    (5,)
    >>> f.u[4] # space point 4
    0.0
    >>>
    >>> # Simple time mesh for two components
    >>> m = Mesh(T=4, dt=0.5)
    >>> print m.dump()
    time: [0,4] Nt=8 dt=0.5
    >>> f = Function(m, num_comp=2)
    >>> f.indices
    ['time', 'component']
    >>> f.u.shape
    (9, 2)
    >>> f.u[3,1] # time point 3, comp=1 (2nd comp.)
    0.0
    >>>
    >>> # 2D space mesh
    >>> m = Mesh(L=[[0,1], [-1,1]], d=[0.5, 1])
    >>> print m.dump()
    space: [0,1]x[-1,1] N=2x2 d=0.5,1
    >>> f = Function(m)
    >>> f.indices
    ['x0', 'x1']
    >>> f.u.shape
    (3, 3)
    >>> f.u[1,2] # space point (1,2)
    0.0
```

```
>>>
>>> # 2D space mesh and time mesh
>>> m = Mesh(L=[[0,1],[-1,1]], d=[0.5,1], Nt=10, T=3)
>>> print m.dump()
space: [0,1]x[-1,1] N=2x2 d=0.5,1 time: [0,3] Nt=10 dt=0.3
>>> f = Function(m, num_comp=2, space_only=False)
>>> f.indices
['time', 'x0', 'x1', 'component']
>>> f.u.shape
(11, 3, 3, 2)
>>> f.u[2,1,2,0] # time step 2, space point (1,2), comp=0
0.0
>>> # Function with space data only
>>> f = Function(m, num_comp=1, space_only=True)
>>> f.indices
['x0', 'x1']
>>> f.u.shape
(3, 3)
>>> f.u[1,2] # space point (1,2)
0.0
"""
def __init__(self, mesh, num_comp=1, space_only=True):
    self.mesh = mesh
    self.num_comp = num_comp
    self.indices = []
    # Create array(s) to store mesh point values
    if (self.mesh.has_space() and not self.mesh.has_time()) or \
       (self.mesh.has_space() and self.mesh.has_time() and \
        space_only):
        # Space mesh only
        if num_comp == 1:
            self.u = np.zeros(
                [self.mesh.N[i]+1
                 for i in range(len(self.mesh.N))])
            self.indices = [
                'x'+str(i) for i in range(len(self.mesh.N))]
        else:
            self.u = np.zeros(
                [self.mesh.N[i]+1
                 for i in range(len(self.mesh.N))] +
                [num_comp])
            self.indices = [
                'x'+str(i)
                for i in range(len(self.mesh.N))] +\
                ['component']
    if not self.mesh.has_space() and self.mesh.has_time():
        # Time mesh only
        if num_comp == 1:
            self.u = np.zeros(self.mesh.Nt+1)
            self.indices = ['time']
        else:
            # Need num_comp entries per time step
            self.u = np.zeros((self.mesh.Nt+1, num_comp))
            self.indices = ['time', 'component']
```

```
if self.mesh.has_space() and self.mesh.has_time() \
    and not space_only:
    # Space-time mesh
    size = [self.mesh.Nt+1] + \
           [self.mesh.N[i]+1
            for i in range(len(self.mesh.N))]
    if num_comp > 1:
        self.indices = ['time']+\
                       ['x'+str(i)
                        for i in range(len(self.mesh.N))] +\
                       ['component']
        size += [num_comp]
    else:
        self.indices = ['time']+['x'+str(i)
                        for i in range(len(self.mesh.N))]
    self.u = np.zeros(size)
```
#### **C.4.5 Class Solver**

With the Mesh and Function classes in place, we can rewrite the solver function, but we make it a method in class Solver:

```
class Solver(Parameters):
    """
    Numerical parameters for solving the wave equation
    u_tt = (c**2*u_x)_x + f(x,t) with t in [0,T] and
    x in (0,L). The problem definition is implied by
    the method of manufactured solution, choosing
    u(x,t)=x(L-x)(1+t/2) as our solution. This solution
    should be exactly reproduced, provided c is const.
    We simulate in [0, L/2] and apply a symmetry condition
    at the end x=L/2.
    """
    def __init__(self, problem):
        self.problem = problem
        self.prm = dict(C = 0.75, Nx=3, stability_safety_factor=1.0)
        self.type = dict(C=float, Nx=int, stability_safety_factor=float)
        self.help = dict(C='Courant number',
                         Nx='No of spatial mesh points',
                         stability_safety_factor='stability factor')
        from UniformFDMesh import Mesh, Function
        # introduce some local help variables to ease reading
        L_end = self.problem['L']
        dx = (L_end/2)/float(self['Nx'])
        t_interval = self.problem['T']
        dt = dx*self['stability_safety_factor']*self['C']/ \
                                    float(self.problem['c'])
        self.m = Mesh(L=[0,L_end/2],
                      d=[dx],
                      Nt = int(round(t_interval/float(dt))),
                      T=t_interval)
        # The mesh function f will, after solving, contain
        # the solution for the whole domain and all time steps.
        self.f = Function(self.m, num_comp=1, space_only=False)
```

```
def solve(self, user_action=None, version='scalar'):
    # ...use local variables to ease reading
    L, c, T = self.problem['L c T'.split()]
    L = L/2 # compute with half the domain only (symmetry)
    C, Nx, stability_safety_factor = self[
                        'C Nx stability_safety_factor'.split()]
    dx = self.m.d[0]
    I = self.problem.I
    V = self.problem.V
    f = self.problem.f
    U_0 = self.problem.U_0
    U_L = self.problem.U_L
    Nt = self.m.Nt
    t = np.linspace(0, T, Nt+1) # Mesh points in time
    x = np.linspace(0, L, Nx+1) # Mesh points in space
    # Make sure dx and dt are compatible with x and t
    dx = x[1] - x[0]
    dt = t[1] - t[0]
    # Treat c(x) as array
    if isinstance(c, (float,int)):
        c = np.zeros(x.shape) + c
    elif callable(c):
        # Call c(x) and fill array c
        c_ = np.zeros(x.shape)
        for i in range(Nx+1):
            c_[i] = c(x[i])
        c = c_
    q = c**2
    C2 = (dt/dx)**2; dt2 = dt*dt # Help variables in the scheme
    # Wrap user-given f, I, V, U_0, U_L if None or 0
    if f is None or f == 0:
        f = (lambda x, t: 0) if version == 'scalar' else \
            lambda x, t: np.zeros(x.shape)
    if I is None or I == 0:
        I = (lambda x: 0) if version == 'scalar' else \
            lambda x: np.zeros(x.shape)
    if V is None or V == 0:
        V = (lambda x: 0) if version == 'scalar' else \
            lambda x: np.zeros(x.shape)
    if U_0 is not None:
        if isinstance(U_0, (float,int)) and U_0 == 0:
            U_0 = lambda t: 0
    if U_L is not None:
        if isinstance(U_L, (float,int)) and U_L == 0:
            U_L = lambda t: 0
    # Make hash of all input data
    import hashlib, inspect
    data = inspect.getsource(I) + '_' + inspect.getsource(V)+\
           '_' + inspect.getsource(f) + '_' + str(c) + '_' + \
           ('None' if U_0 is None else inspect.getsource(U_0)) + \
           ('None' if U_L is None else inspect.getsource(U_L)) + \
           '_' + str(L) + str(dt) + '_' + str(C) + '_' + str(T)+\
           '_' + str(stability_safety_factor)
```

```
y y
hashed_input = hashlib.sha1(data).hexdigest()
if os.path.isfile('.' + hashed_input + '_archive.npz'):
    # Simulation is already run
    return -1, hashed_input
# use local variables to make code closer to mathematical
# notation in computational scheme
u_1 = self.f.u[0,:]
u = self.f.u[1,:]
import time; t0 = time.clock() # CPU time measurement
Ix = range(0, Nx+1)
It = range(0, Nt+1)
# Load initial condition into u_1
for i in range(0,Nx+1):
    u_1[i] = I(x[i])
if user_action is not None:
    user_action(u_1, x, t, 0)
# Special formula for the first step
for i in Ix[1:-1]:
    u[i] = u_1[i] + dt*V(x[i]) + \
    0.5*C2*(0.5*(q[i] + q[i+1])*(u_1[i+1] - u_1[i]) - \
            0.5*(q[i] + q[i-1])*(u_1[i] - u_1[i-1])) + \
    0.5*dt2*f(x[i], t[0])
i = Ix[0]
if U_0 is None:
    # Set boundary values (x=0: i-1 -> i+1 since u[i-1]=u[i+1]
    # when du/dn = 0, on x=L: i+1 -> i-1 since u[i+1]=u[i-1])
    ip1 = i+1
    im1 = ip1 # i-1 -> i+1
    u[i] = u_1[i] + dt*V(x[i]) + \
           0.5*C2*(0.5*(q[i] + q[ip1])*(u_1[ip1] - u_1[i]) - \
                   0.5*(q[i] + q[im1])*(u_1[i] - u_1[im1])) + \
    0.5*dt2*f(x[i], t[0])
else:
    u[i] = U_0(dt)
i = Ix[-1]
if U_L is None:
    im1 = i-1
    ip1 = im1 # i+1 -> i-1
    u[i] = u_1[i] + dt*V(x[i]) + \
           0.5*C2*(0.5*(q[i] + q[ip1])*(u_1[ip1] - u_1[i]) - \
                   0.5*(q[i] + q[im1])*(u_1[i] - u_1[im1])) + \
    0.5*dt2*f(x[i], t[0])
else:
    u[i] = U_L(dt)
if user_action is not None:
    user_action(u, x, t, 1)
```

```
for n in It[1:-1]:
    # u corresponds to u^{n+1} in the mathematical scheme
    u_2 = self.f.u[n-1,:]
    u_1 = self.f.u[n,:]
    u = self.f.u[n+1,:]
    # Update all inner points
    if version == 'scalar':
        for i in Ix[1:-1]:
            u[i]=- u_2[i] + 2*u_1[i]+\
                C2*(0.5*(q[i] + q[i+1])*(u_1[i+1] - u_1[i]) - \
                    0.5*(q[i] + q[i-1])*(u_1[i] - u_1[i-1])) + \
            dt2*f(x[i], t[n])
    elif version == 'vectorized':
        u[1:-1] = - u_2[1:-1] + 2*u_1[1:-1] + \
        C2*(0.5*(q[1:-1] + q[2:])*(u_1[2:] - u_1[1:-1]) -
            0.5*(q[1:-1] + q[:-2])*(u_1[1:-1] - u_1[:-2])) + \
        dt2*f(x[1:-1], t[n])
    else:
        raise ValueError('version=%s' % version)
    # Insert boundary conditions
    i = Ix[0]
    if U_0 is None:
        # Set boundary values
        # x=0: i-1 -> i+1 since u[i-1]=u[i+1] when du/dn=0
        # x=L: i+1 -> i-1 since u[i+1]=u[i-1] when du/dn=0
        ip1 = i+1
        im1 = ip1
        u[i]=- u_2[i] + 2*u_1[i]+\
               C2*(0.5*(q[i] + q[ip1])*(u_1[ip1] - u_1[i]) - \
                   0.5*(q[i] + q[im1])*(u_1[i] - u_1[im1])) + \
        dt2*f(x[i], t[n])
    else:
        u[i] = U_0(t[n+1])
    i = Ix[-1]
    if U_L is None:
        im1 = i-1
        ip1 = im1
        u[i]=- u_2[i] + 2*u_1[i]+\
               C2*(0.5*(q[i] + q[ip1])*(u_1[ip1] - u_1[i]) - \
                   0.5*(q[i] + q[im1])*(u_1[i] - u_1[im1])) + \
        dt2*f(x[i], t[n])
    else:
        u[i] = U_L(t[n+1])
    if user_action is not None:
        if user_action(u, x, t, n+1):
            break
cpu_time = time.clock() - t0
return cpu_time, hashed_input
```

```
def assert_no_error(self):
    """Run through mesh and check error"""
    Nx = self['Nx']
    Nt = self.m.Nt
    L, T = self.problem['L T'.split()]
    L = L/2 # only half the domain used (symmetry)
    x = np.linspace(0, L, Nx+1) # Mesh points in space
    t = np.linspace(0, T, Nt+1) # Mesh points in time
    for n in range(len(t)):
        u_e = self.problem.u_exact(x, t[n])
        diff = np.abs(self.f.u[n,:] - u_e).max()
        print 'diff:', diff
        tol = 1E-13
        assert diff < tol
```
Observe that the solutions from all time steps are stored in the mesh function, which allows error assessment (in assert\_no\_error) to take place after all solutions have been found. Of course, in 2D or 3D, such a strategy may place too high demands on available computer memory, in which case intermediate results could be stored on file.

Running wave1D\_oo.py gives a printout showing that the class-based implementation performs as expected, i.e. that the known exact solution is reproduced (within machine precision).

#### **C.5 Migrating Loops to Cython**

We now consider the wave2D\_u0.py code for solving the 2D linear wave equation with constant wave velocity and homogeneous Dirichlet boundary conditions <sup>u</sup> D <sup>0</sup>. We shall in the present chapter extend this code with computational modules written in other languages than Python. This extended version is called wave2D\_u0\_adv.py.

The wave2D\_u0.py file contains a solver function, which calls an advance\_\* function to advance the numerical scheme one level forward in time. The function advance\_scalar applies standard Python loops to implement the scheme, while advance\_vectorized performs corresponding vectorized arithmetics with array slices. The statements of this solver are explained in Sect. 2.12, in particular Sect. 2.12.1 and 2.12.2.

Although vectorization can bring down the CPU time dramatically compared with scalar code, there is still some factor 5-10 to win in these types of applications by implementing the finite difference scheme in compiled code, typically in Fortran, C, or C++. This can quite easily be done by adding a little extra code to our program. Cython is an extension of Python that offers the easiest way to nail our Python loops in the scalar code down to machine code and achieve the efficiency of C.

Cython can be viewed as an extended Python language where variables are declared with types and where functions are marked to be implemented in C. Migrating Python code to Cython is done by copying the desired code segments to functions (or classes) and placing them in one or more separate files with extension .pyx.

#### **C.5.1 Declaring Variables and Annotating the Code**

Our starting point is the plain advance\_scalar function for a scalar implementation of the updating algorithm for new values u<sup>n</sup>C<sup>1</sup> i;j :

```
def advance_scalar(u, u_n, u_nm1, f, x, y, t, n, Cx2, Cy2, dt2,
                   V=None, step1=False):
    Ix = range(0, u.shape[0]); Iy = range(0, u.shape[1])
    if step1:
        dt = sqrt(dt2) # save
        Cx2 = 0.5*Cx2; Cy2 = 0.5*Cy2; dt2 = 0.5*dt2 # redefine
        D1 = 1; D2 = 0
    else:
        D1 = 2; D2 = 1
    for i in Ix[1:-1]:
        for j in Iy[1:-1]:
            u_xx = u_n[i-1,j] - 2*u_n[i,j] + u_n[i+1,j]
            u_yy = u_n[i,j-1] - 2*u_n[i,j] + u_n[i,j+1]
            u[i,j] = D1*u_n[i,j] - D2*u_nm1[i,j]+\
                     Cx2*u_xx + Cy2*u_yy + dt2*f(x[i], y[j], t[n])
            if step1:
                u[i,j] += dt*V(x[i], y[j])
    # Boundary condition u=0
    j = Iy[0]
    for i in Ix: u[i,j]=0
    j = Iy[-1]
    for i in Ix: u[i,j]=0
    i = Ix[0]
    for j in Iy: u[i,j]=0
    i = Ix[-1]
    for j in Iy: u[i,j]=0
    return u
```
We simply take a copy of this function and put it in a file wave2D\_u0\_loop\_cy. pyx. The relevant Cython implementation arises from declaring variables with types and adding some important annotations to speed up array computing in Cython. Let us first list the complete code in the .pyx file:

```
import numpy as np
cimport numpy as np
cimport cython
ctypedef np.float64_t DT # data type
@cython.boundscheck(False) # turn off array bounds check
@cython.wraparound(False) # turn off negative indices (u[-1,-1])
cpdef advance(
    np.ndarray[DT, ndim=2, mode='c'] u,
    np.ndarray[DT, ndim=2, mode='c'] u_n,
    np.ndarray[DT, ndim=2, mode='c'] u_nm1,
    np.ndarray[DT, ndim=2, mode='c'] f,
    double Cx2, double Cy2, double dt2):
```

```
cdef:
    int Ix_start = 0
    int Iy_start = 0
    int Ix_end = u.shape[0]-1
    int Iy_end = u.shape[1]-1
    int i, j
    double u_xx, u_yy
for i in range(Ix_start+1, Ix_end):
    for j in range(Iy_start+1, Iy_end):
        u_xx = u_n[i-1,j] - 2*u_n[i,j] + u_n[i+1,j]
        u_yy = u_n[i,j-1] - 2*u_n[i,j] + u_n[i,j+1]
        u[i,j] = 2*u_n[i,j] - u_nm1[i,j]+\
                 Cx2*u_xx + Cy2*u_yy + dt2*f[i,j]
# Boundary condition u=0
j = Iy_start
for i in range(Ix_start, Ix_end+1): u[i,j]=0
j = Iy_end
for i in range(Ix_start, Ix_end+1): u[i,j]=0
i = Ix_start
for j in range(Iy_start, Iy_end+1): u[i,j]=0
i = Ix_end
for j in range(Iy_start, Iy_end+1): u[i,j]=0
return u
```
This example may act as a recipe on how to transform array-intensive code with loops into Cython.

	- the type np.ndarray,
	- the data type of the elements, here 64-bit floats, abbreviated as DT through ctypedef np.float64\_t DT (instead of DT we could use the full name of the data type: np.float64\_t, which is a Cython-defined type),
	- the dimensions of the array, here ndim=2 and ndim=1,
	- specification of contiguous memory for the array (mode='c').

indices, and these are now turned off. Another reason is that Cython loops must take the form for i in xrange or for i in range for being translated into efficient C loops. We have therefore introduced Ix\_start as Ix[0] and Ix\_end as Ix[-1] to hold the start and end of the values of index i. Similar variables are introduced for the j index. A loop for i in Ix is with these new variables written as for i in range(Ix\_start, Ix\_end+1).

**Array declaration syntax in Cython**

We have used the syntax np.ndarray[DT, ndim=2, mode='c'] to declare numpy arrays in Cython. There is a simpler, alternative syntax, employing typed memory views1, where the declaration looks like double [:,:]. However, the full support for this functionality is not yet ready, and in this text we use the full array declaration syntax.

#### **C.5.2 Visual Inspection of the C Translation**

Cython can visually explain how successfully it translated a code from Python to C. The command

```
Terminal
Terminal> cython -a wave2D_u0_loop_cy.pyx
```
produces an HTML file wave2D\_u0\_loop\_cy.html, which can be loaded into a web browser to illustrate which lines of the code that have been translated to C. Figure C.1 shows the illustrated code. Yellow lines indicate the lines that Cython

**Fig. C.1** Visual illustration of Cython's ability to translate Python to C

<sup>1</sup> http://docs.cython.org/src/userguide/memoryviews.html

did not manage to translate to efficient C code and that remain in Python. For the present code we see that Cython is able to translate all the loops with array computing to C, which is our primary goal.

You can also inspect the generated C code directly, as it appears in the file wave2D\_u0\_loop\_cy.c. Nevertheless, understanding this C code requires some familiarity with writing Python extension modules in C by hand. Deep down in the file we can see in detail how the compute-intensive statements have been translated into some complex C code that is quite different from what a human would write (at least if a direct correspondence to the mathematical notation was intended).

#### **C.5.3 Building the Extension Module**

Cython code must be translated to C, compiled, and linked to form what is known in the Python world as a *C extension module*. This is usually done by making a setup.py script, which is the standard way of building and installing Python software. For an extension module arising from Cython code, the following setup.py script is all we need to build and install the module:

```
from distutils.core import setup
from distutils.extension import Extension
from Cython.Distutils import build_ext
cymodule = 'wave2D_u0_loop_cy'
setup(
  name=cymodule
  ext_modules=[Extension(cymodule, [cymodule + '.pyx'],)],
  cmdclass={'build_ext': build_ext},
)
```
We run the script by

Terminal Terminal> python setup.py build\_ext --inplace

The –inplace option makes the extension module available in the current directory as the file wave2D\_u0\_loop\_cy.so. This file acts as a normal Python module that can be imported and inspected:

```
>>> import wave2D_u0_loop_cy
>>> dir(wave2D_u0_loop_cy)
['__builtins__', '__doc__', '__file__', '__name__',
 '__package__', '__test__', 'advance', 'np']
```
The important output from the dir function is our Cython function advance (the module also features the imported numpy module under the name np as well as many standard Python objects with double underscores in their names).

The setup.py file makes use of the distutils package in Python and Cython's extension of this package. These tools know how Python was built on the computer and will use compatible compiler(s) and options when building other code in Cython, C, or C++. Quite some experience with building large program systems is needed to do the build process manually, so using a setup.py script is strongly recommended.

#### **Simplified build of a Cython module**

When there is no need to link the C code with special libraries, Cython offers a shortcut for generating and importing the extension module:

import pyximport; pyximport.install()

This makes the setup.py script redundant. However, in the wave2D\_u0\_adv.py code we do not use pyximport and require an explicit build process of this and many other modules.

#### **C.5.4 Calling the Cython Function from Python**

The wave2D\_u0\_loop\_cy module contains our advance function, which we now may call from the Python program for the wave equation:

```
import wave2D_u0_loop_cy
advance = wave2D_u0_loop_cy.advance
...
for n in It[1:-1]: # time loop
   f_a[:,:] = f(xv, yv, t[n]) # precompute, size as u
   u = advance(u, u_n, u_nm1, f_a, x, y, t, Cx2, Cy2, dt2)
```
**Efficiency** For a mesh consisting of <sup>120</sup>-120 cells, the scalar Python code requires 1370 CPU time units, the vectorized version requires 5.5, while the Cython version requires only 1! For a smaller mesh with <sup>60</sup> - 60 cells Cython is about 1000 times faster than the scalar Python code, and the vectorized version is about 6 times slower than the Cython version.

#### **C.6 Migrating Loops to Fortran**

Instead of relying on Cython's (excellent) ability to translate Python to C, we can invoke a compiled language directly and write the loops ourselves. Let us start with Fortran 77, because this is a language with more convenient array handling than C (or plain C++), because we can use the same multi-dimensional indices in the Fortran code as in the numpy arrays in the Python code, while in C these arrays are one-dimensional and require us to reduce multi-dimensional indices to a single index.

#### **C.6.1 The Fortran Subroutine**

We write a Fortran subroutine advance in a file wave2D\_u0\_loop\_f77.f for implementing the updating formula (2.117) and setting the solution to zero at the boundaries:

```
subroutine advance(u, u_n, u_nm1, f, Cx2, Cy2, dt2, Nx, Ny)
      integer Nx, Ny
      real*8 u(0:Nx,0:Ny), u_n(0:Nx,0:Ny), u_nm1(0:Nx,0:Ny)
      real*8 f(0:Nx,0:Ny), Cx2, Cy2, dt2
      integer i, j
      real*8 u_xx, u_yy
Cf2py intent(in, out) u
C Scheme at interior points
      do j = 1, Ny-1
         do i = 1, Nx-1
            u_xx = u_n(i-1,j) - 2*u_n(i,j) + u_n(i+1,j)
            u_yy = u_n(i,j-1) - 2*u_n(i,j) + u_n(i,j+1)
            u(i,j) = 2*u_n(i,j) - u_nm1(i,j) + Cx2*u_xx + Cy2*u_yy +
     & dt2*f(i,j)
         end do
      end do
C Boundary conditions
      j = 0
      do i = 0, Nx
         u(i,j)=0
      end do
      j = Ny
      do i = 0, Nx
         u(i,j)=0
      end do
      i = 0
      do j = 0, Ny
         u(i,j)=0
      end do
      i = Nx
      do j = 0, Ny
         u(i,j)=0
      end do
      return
      end
```
This code is plain Fortran 77, except for the special Cf2py comment line, which here specifies that u is both an input argument *and* an object to be returned from the advance routine. Or more precisely, Fortran is not able return an array from a function, but we need a *wrapper code* in C for the Fortran subroutine to enable calling it from Python, and from this wrapper code one can return u to the calling Python code.

#### **Tip: Return all computed objects to the calling code**

It is not strictly necessary to return u to the calling Python code since the advance function will modify the elements of u, but the convention in Python is to get all output from a function as returned values. That is, the right way of calling the above Fortran subroutine from Python is

u = advance(u, u\_n, u\_nm1, f, Cx2, Cy2, dt2)

The less encouraged style, which works and resembles the way the Fortran subroutine is called from Fortran, reads

advance(u, u\_n, u\_nm1, f, Cx2, Cy2, dt2)

#### **C.6.2 Building the Fortran Module with f2py**

The nice feature of writing loops in Fortran is that, without much effort, the tool f2py can produce a C extension module such that we can call the Fortran version of advance from Python. The necessary commands to run are

```
Terminal
Terminal> f2py -m wave2D_u0_loop_f77 -h wave2D_u0_loop_f77.pyf \
          --overwrite-signature wave2D_u0_loop_f77.f
Terminal> f2py -c wave2D_u0_loop_f77.pyf --build-dir build_f77 \
          -DF2PY_REPORT_ON_ARRAY_COPY=1 wave2D_u0_loop_f77.f
```
The first command asks f2py to interpret the Fortran code and make a Fortran 90 specification of the extension module in the file wave2D\_u0\_loop\_f77.pyf. The second command makes f2py generate all necessary wrapper code, compile our Fortran file and the wrapper code, and finally build the module. The build process takes place in the specified subdirectory build\_f77 so that files can be inspected if something goes wrong. The option -DF2PY\_REPORT\_ON\_ARRAY\_COPY=1 makes f2py write a message for every array that is copied in the communication between Fortran and Python, which is very useful for avoiding unnecessary array copying (see below). The name of the module file is wave2D\_u0\_loop\_f77.so, and this file can be imported and inspected as any other Python module:

```
>>> import wave2D_u0_loop_f77
>>> dir(wave2D_u0_loop_f77)
['__doc__', '__file__', '__name__', '__package__',
 '__version__', 'advance']
>>> print wave2D_u0_loop_f77.__doc__
This module 'wave2D_u0_loop_f77' is auto-generated with f2py....
Functions:
  u = advance(u,u_n,u_nm1,f,cx2,cy2,dt2,
      nx=(shape(u,0)-1),ny=(shape(u,1)-1))
```
#### **Examine the doc strings!**

Printing the doc strings of the module and its functions is extremely important after having created a module with f2py. The reason is that f2py makes Python interfaces to the Fortran functions that are different from how the functions are declared in the Fortran code (!). The rationale for this behavior is that f2py creates *Pythonic* interfaces such that Fortran routines can be called in the same way as one calls Python functions. Output data from Python functions is always returned to the calling code, but this is technically impossible in Fortran. Also, arrays in Python are passed to Python functions without their dimensions because that information is packed with the array data in the array objects. This is not possible in Fortran, however. Therefore, f2py removes array dimensions from the argument list, and f2py makes it possible to return objects back to Python.

Let us follow the advice of examining the doc strings and take a close look at the documentation f2py has generated for our Fortran advance subroutine:

```
>>> print wave2D_u0_loop_f77.advance.__doc__
This module 'wave2D_u0_loop_f77' is auto-generated with f2py
Functions:
  u = advance(u,u_n,u_nm1,f,cx2,cy2,dt2,
              nx=(shape(u,0)-1),ny=(shape(u,1)-1))
.
advance - Function signature:
  u = advance(u,u_n,u_nm1,f,cx2,cy2,dt2,[nx,ny])
Required arguments:
  u : input rank-2 array('d') with bounds (nx + 1,ny + 1)
  u_n : input rank-2 array('d') with bounds (nx + 1,ny + 1)
  u_nm1 : input rank-2 array('d') with bounds (nx + 1,ny + 1)
  f : input rank-2 array('d') with bounds (nx + 1,ny + 1)
  cx2 : input float
  cy2 : input float
  dt2 : input float
Optional arguments:
  nx := (shape(u,0)-1) input int
  ny := (shape(u,1)-1) input int
Return objects:
  u : rank-2 array('d') with bounds (nx + 1,ny + 1)
```
Here we see that the nx and ny parameters declared in Fortran are optional arguments that can be omitted when calling advance from Python.

We strongly recommend to print out the documentation of *every* Fortran function to be called from Python and make sure the call syntax is exactly as listed in the documentation.

#### **C.6.3 How to Avoid Array Copying**

Multi-dimensional arrays are stored as a stream of numbers in memory. For a twodimensional array consisting of rows and columns there are two ways of creating such a stream: *row-major ordering*, which means that rows are stored consecutively in memory, or *column-major ordering*, which means that the columns are stored one after each other. All programming languages inherited from C, including Python, apply the row-major ordering, but Fortran uses column-major storage. Thinking of a two-dimensional array in Python or C as a matrix, it means that Fortran works with the transposed matrix.

Fortunately, f2py creates extra code so that accessing u(i,j) in the Fortran subroutine corresponds to the element u[i,j] in the underlying numpy array (without the extra code, u(i,j) in Fortran would access u[j,i] in the numpy array). Technically, f2py takes a copy of our numpy array and reorders the data before sending the array to Fortran. Such copying can be costly. For 2D wave simulations on a <sup>60</sup> - 60 grid the overhead of copying is a factor of 5, which means that almost the whole performance gain of Fortran over vectorized numpy code is lost!

To avoid having f2py to copy arrays with C storage to the corresponding Fortran storage, we declare the arrays with Fortran storage:

```
order = 'Fortran' if version == 'f77' else 'C'
u = zeros((Nx+1,Ny+1), order=order) # solution array
u_n = zeros((Nx+1,Ny+1), order=order) # solution at t-dt
u_nm1 = zeros((Nx+1,Ny+1), order=order) # solution at t-2*dt
```
In the compile and build step of using f2py, it is recommended to add an extra option for making f2py report on array copying:


It can sometimes be a challenge to track down which array that causes a copying. There are two principal reasons for copying array data: either the array does not have Fortran storage or the element types do not match those declared in the Fortran code. The latter cause is usually effectively eliminated by using real\*8 data in the Fortran code and float64 (the default float type in numpy) in the arrays on the Python side. The former reason is more common, and to check whether an array before a Fortran call has the right storage one can print the result of isfortran(a), which is True if the array a has Fortran storage.

Let us look at an example where we face problems with array storage. A typical problem in the wave2D\_u0.py code is to set

f\_a = f(xv, yv, t[n])

before the call to the Fortran advance routine. This computation creates a new array with C storage. An undesired copy of f\_a will be produced when sending f\_a to a Fortran routine. There are two remedies, either direct insertion of data in an array with Fortran storage,

```
f_a = zeros((Nx+1, Ny+1), order='Fortran')
...
f_a[:,:] = f(xv, yv, t[n])
```
or remaking the f(xv, yv, t[n]) array,

f\_a = asarray(f(xv, yv, t[n]), order='Fortran')

The former remedy is most efficient if the asarray operation is to be performed a large number of times.

**Efficiency** The efficiency of this Fortran code is very similar to the Cython code. There is usually nothing more to gain, from a computational efficiency point of view, by implementing the *complete* Python program in Fortran or C. That will just be a lot more code for all administering work that is needed in scientific software, especially if we extend our sample program wave2D\_u0.py to handle a real scientific problem. Then only a small portion will consist of loops with intensive array calculations. These can be migrated to Cython or Fortran as explained, while the rest of the programming can be more conveniently done in Python.

#### **C.7 Migrating Loops to C via Cython**

The computationally intensive loops can alternatively be implemented in C code. Just as Fortran calls for care regarding the storage of two-dimensional arrays, working with two-dimensional arrays in C is a bit tricky. The reason is that numpy arrays are viewed as one-dimensional arrays when transferred to C, while C programmers will think of u, u\_n, and u\_nm1 as two dimensional arrays and index them like u[i][j]. The C code must declare u as double\* u and translate an index pair [i][j] to a corresponding single index when u is viewed as one-dimensional. This translation requires knowledge of how the numbers in u are stored in memory.

#### **C.7.1 Translating Index Pairs to Single Indices**

Two-dimensional numpy arrays with the default C storage are stored row by row. In general, multi-dimensional arrays with C storage are stored such that the last index has the fastest variation, then the next last index, and so on, ending up with the slowest variation in the first index. For a two-dimensional u declared as zeros((Nx+1,Ny+1)) in Python, the individual elements are stored in the following order:

```
u[0,0], u[0,1], u[0,2], ..., u[0,Ny], u[1,0], u[1,1], ...,
u[1,Ny], u[2,0], ..., u[Nx,0], u[Nx,1], ..., u[Nx, Ny]
```
Viewing <sup>u</sup> as one-dimensional, the index pair .i; j / translates to i.Ny <sup>C</sup> 1/ <sup>C</sup> <sup>j</sup> . So, where a C programmer would naturally write an index u[i][j], the indexing must read u[i\*(Ny+1) + j]. This is tedious to write, so it can be handy to define a C macro,

#define idx(i,j) (i)\*(Ny+1) + j

so that we can write u[idx(i,j)], which reads much better and is easier to debug.

#### **Be careful with macro definitions**

Macros just perform simple text substitutions: idx(hello,world) is expanded to (hello)\*(Ny+1) + world. The parentheses in (i) are essential – using the natural mathematical formula i\*(Ny+1) + j in the macro definition, idx(i-1,j) would expand to i-1\*(Ny+1) + j, which is the wrong formula. Macros are handy, but require careful use. In C++, inline functions are safer and replace the need for macros.

#### **C.7.2 The Complete C Code**

The C version of our function advance can be coded as follows.

```
#define idx(i,j) (i)*(Ny+1) + j
void advance(double* u, double* u_n, double* u_nm1, double* f,
         double Cx2, double Cy2, double dt2, int Nx, int Ny)
{
  int i, j;
  double u_xx, u_yy;
  /* Scheme at interior points */
  for (i=1; i<=Nx-1; i++) {
    for (j=1; j<=Ny-1; j++) {
      u_xx = u_n[idx(i-1,j)] - 2*u_n[idx(i,j)] + u_n[idx(i+1,j)];
      u_yy = u_n[idx(i,j-1)] - 2*u_n[idx(i,j)] + u_n[idx(i,j+1)];
      u[idx(i,j)] = 2*u_n[idx(i,j)] - u_nm1[idx(i,j)] +
    Cx2*u_xx + Cy2*u_yy + dt2*f[idx(i,j)];
    }
  }
  /* Boundary conditions */
  j = 0; for (i=0; i<=Nx; i++) u[idx(i,j)] = 0;
  j = Ny; for (i=0; i<=Nx; i++) u[idx(i,j)] = 0;
  i = 0; for (j=0; j<=Ny; j++) u[idx(i,j)] = 0;
  i = Nx; for (j=0; j<=Ny; j++) u[idx(i,j)] = 0;
}
```
### **C.7.3 The Cython Interface File**

All the code above appears in the file wave2D\_u0\_loop\_c.c. We need to compile this file together with C wrapper code such that advance can be called from Python. Cython can be used to generate appropriate wrapper code. The relevant Cython code for interfacing C is placed in a file with extension .pyx. This file, called wave2D\_u0\_loop\_c\_cy.pyx<sup>2</sup> , looks like

```
import numpy as np
cimport numpy as np
cimport cython
```
<sup>2</sup> http://tinyurl.com/nu656p2/softeng2/wave2D\_u0\_loop\_c\_cy.pyx

```
cdef extern from "wave2D_u0_loop_c.h":
    void advance(double* u, double* u_n, double* u_nm1, double* f,
                 double Cx2, double Cy2, double dt2,
                 int Nx, int Ny)
@cython.boundscheck(False)
@cython.wraparound(False)
def advance_cwrap(
    np.ndarray[double, ndim=2, mode='c'] u,
    np.ndarray[double, ndim=2, mode='c'] u_n,
    np.ndarray[double, ndim=2, mode='c'] u_nm1,
    np.ndarray[double, ndim=2, mode='c'] f,
    double Cx2, double Cy2, double dt2):
    advance(&u[0,0], &u_n[0,0], &u_nm1[0,0], &f[0,0],
            Cx2, Cy2, dt2,
            u.shape[0]-1, u.shape[1]-1)
    return u
```
We first declare the C functions to be interfaced. These must also appear in a C header file, wave2D\_u0\_loop\_c.h,

```
extern void advance(double* u, double* u_n, double* u_nm1, double* f,
            double Cx2, double Cy2, double dt2,
            int Nx, int Ny);
```
The next step is to write a Cython function with Python objects as arguments. The name advance is already used for the C function so the function to be called from Python is named advance\_cwrap. The contents of this function is simply a call to the advance version in C. To this end, the right information from the Python objects must be passed on as arguments to advance. Arrays are sent with their C pointers to the first element, obtained in Cython as &u[0,0] (the & takes the address of a C variable). The Nx and Ny arguments in advance are easily obtained from the shape of the numpy array u. Finally, u must be returned such that we can set u = advance(...) in Python.

#### **C.7.4 Building the Extension Module**

It remains to build the extension module. An appropriate setup.py file is

```
from distutils.core import setup
from distutils.extension import Extension
from Cython.Distutils import build_ext
sources = ['wave2D_u0_loop_c.c', 'wave2D_u0_loop_c_cy.pyx']
module = 'wave2D_u0_loop_c_cy'
setup(
  name=module,
  ext_modules=[Extension(module, sources,
                         libraries=[], # C libs to link with
                         )],
  cmdclass={'build_ext': build_ext},
)
```
All we need to specify is the .c file(s) and the .pyx interface file. Cython is automatically run to generate the necessary wrapper code. Files are then compiled and linked to an extension module residing in the file wave2D\_u0\_loop\_c\_cy.so. Here is a session with running setup.py and examining the resulting module in Python

```
Terminal
Terminal> python setup.py build_ext --inplace
Terminal> python
>>> import wave2D_u0_loop_c_cy as m
>>> dir(m)
['__builtins__', '__doc__', '__file__', '__name__', '__package__',
 '__test__', 'advance_cwrap', 'np']
```
The call to the C version of advance can go like this in Python:

```
import wave2D_u0_loop_c_cy
advance = wave2D_u0_loop_c_cy.advance_cwrap
...
f_a[:,:] = f(xv, yv, t[n])
u = advance(u, u_n, u_nm1, f_a, Cx2, Cy2, dt2)
```
**Efficiency** In this example, the C and Fortran code runs at the same speed, and there are no significant differences in the efficiency of the wrapper code. The overhead implied by the wrapper code is negligible as long as there is little numerical work in the advance function, or in other words, that we work with small meshes.

### **C.8 Migrating Loops to C via f2py**

An alternative to using Cython for interfacing C code is to apply f2py. The C code is the same, just the details of specifying how it is to be called from Python differ. The f2py tool requires the call specification to be a Fortran 90 module defined in a .pyf file. This file was automatically generated when we interfaced a Fortran subroutine. With a C function we need to write this module ourselves, or we can use a trick and let f2py generate it for us. The trick consists in writing the signature of the C function with Fortran syntax and place it in a Fortran file, here wave2D\_u0\_loop\_c\_f2py\_signature.f:

```
subroutine advance(u, u_n, u_nm1, f, Cx2, Cy2, dt2, Nx, Ny)
Cf2py intent(c) advance
      integer Nx, Ny, N
      real*8 u(0:Nx,0:Ny), u_n(0:Nx,0:Ny), u_nm1(0:Nx,0:Ny)
      real*8 f(0:Nx, 0:Ny), Cx2, Cy2, dt2
Cf2py intent(in, out) u
Cf2py intent(c) u, u_n, u_nm1, f, Cx2, Cy2, dt2, Nx, Ny
      return
      end
```
Note that we need a special f2py instruction, through a Cf2py comment line, to specify that all the function arguments are C variables. We also need to tell that the function is actually in C: intent(c) advance.

Since f2py is just concerned with the function signature and not the complete contents of the function body, it can easily generate the Fortran 90 module specification based solely on the signature above:

The compile and build step is as for the Fortran code, except that we list C files instead of Fortran files:

```
Terminal
Terminal> f2py -c wave2D_u0_loop_c_f2py.pyf \
          --build-dir tmp_build_c \
          -DF2PY_REPORT_ON_ARRAY_COPY=1 wave2D_u0_loop_c.c
```
As when interfacing Fortran code with f2py, we need to print out the doc string to see the exact call syntax from the Python side. This doc string is identical for the C and Fortran versions of advance.

#### **C.8.1 Migrating Loops to C++ via f2py**

C++ is a much more versatile language than C or Fortran and has over the last two decades become very popular for numerical computing. Many will therefore prefer to migrate compute-intensive Python code to C++. This is, in principle, easy: just write the desired C++ code and use some tool for interfacing it from Python. A tool like SWIG3 can interpret the C++ code and generate interfaces for a wide range of languages, including Python, Perl, Ruby, and Java. However, SWIG is a comprehensive tool with a correspondingly steep learning curve. Alternative tools, such as Boost Python4, SIP5, and Shiboken<sup>6</sup> are similarly comprehensive. Simpler tools include PyBindGen7.

A technically much easier way of interfacing C++ code is to drop the possibility to use C++ classes directly from Python, but instead make a C interface to the C++ code. The C interface can be handled by f2py as shown in the example with pure C code. Such a solution means that classes in Python and C++ cannot be mixed and that only primitive data types like numbers, strings, and arrays can be transferred between Python and C++. Actually, this is often a very good solution because it

<sup>3</sup> http://swig.org/

<sup>4</sup> http://www.boost.org/doc/libs/1\_51\_0/libs/python/doc/index.html

<sup>5</sup> http://riverbankcomputing.co.uk/software/sip/intro

<sup>6</sup> http://qt-project.org/wiki/Category:LanguageBindings::PySide::Shiboken

<sup>7</sup> http://code.google.com/p/pybindgen/

forces the C++ code to work on array data, which usually gives faster code than if fancy data structures with classes are used. The arrays coming from Python, and looking like plain C/C++ arrays, can be efficiently wrapped in more user-friendly C++ array classes in the C++ code, if desired.

#### **C.9 Exercises**

#### **Exercise C.1: Explore computational efficiency of numpy.sum versus built-in sum**

Using the task of computing the sum of the first n integers, we want to compare the efficiency of numpy.sum versus Python's built-in function sum. Use IPython's %timeit functionality to time these two functions applied to three different arguments: range(n), xrange(n), and arange(n).

Filename: sumn.

#### **Exercise C.2: Make an improved** numpy.savez **function**

The numpy.savez function can save multiple arrays to a zip archive. Unfortunately, if we want to use savez in time-dependent problems and call it multiple times (once per time level), each call leads to a separate zip archive. It is more convenient to have all arrays in one archive, which can be read by numpy.load. Section C.2 provides a recipe for merging all the individual zip archives into one archive. An alternative is to write a new savez function that allows multiple calls and storage into the same archive prior to a final close method to close the archive and make it ready for reading. Implement such an improved savez function as a class Savez.

The class should pass the following unit test:

```
def test_Savez():
    import tempfile, os
    tmp = 'tmp_testarchive'
    database = Savez(tmp)
    for i in range(4):
        array = np.linspace(0, 5+i, 3)
        kwargs = {'myarray_%02d' % i: array}
        database.savez(**kwargs)
    database.close()
    database = np.load(tmp+'.npz')
    expected = {
        'myarray_00': np.array([ 0. , 2.5, 5. ]),
        'myarray_01': np.array([ 0., 3., 6.])
        'myarray_02': np.array([ 0. , 3.5, 7. ]),
        'myarray_03': np.array([ 0., 4., 8.]),
        }
    for name in database:
        computed = database[name]
        diff = np.abs(expected[name] - computed).max()
        assert diff < 1E-13
    database.close
    os.remove(tmp+'.npz')
```
*Hint* Study the source code<sup>8</sup> for function savez (or more precisely, function \_savez).

Filename: Savez.

#### **Exercise C.3: Visualize the impact of the Courant number**

Use the pulse function in the wave1D\_dn\_vc.py to simulate a pulse through two media with different wave velocities. The aim is to visualize the impact of the Courant number C on the quality of the solution. Set slowness\_factor=4 and Nx=100.

Simulate for <sup>C</sup> D 1; 0:9; 0:75 and make an animation comparing the three curves (use the animate\_archives.py program to combine the curves and make animations on the screen and video files). Perform the investigations for different types of initial profiles: a Gaussian pulse, a "cosine hat" pulse, half a "cosine hat" pulse, and a plug pulse.

Filename: pulse1D\_Courant.

#### **Exercise C.4: Visualize the impact of the resolution**

We solve the same set of problems as in Exercise C.3, except that we now fix <sup>C</sup> D <sup>1</sup> and instead study the impact of t and x by varying the Nx parameter: 20, 40, 160. Make animations comparing three such curves.

Filename: pulse1D\_Nx.

<sup>8</sup> https://github.com/numpy/numpy/blob/master/numpy/lib/npyio.py

## **References**


## **Index**

1st-order ODE, 29 2nd-order ODE, 29 3D visualization, 177

#### **A**

accuracy, 234 Adams-Bashforth, 392 ADI methods, 387 advec1D.py, 328 alternating mesh, 46 amplification factor, 234 angular frequency, 1 animation, 13 animation speed, 110 argparse (Python module), 62 ArgumentParser (Python class), 62 arithmetic mean, 137, 359 array computing, 115 array slices, 115 array slices (2D), 174 array updating, 121 as\_ordered\_terms, 161 assert, 7 averaging arithmetic, 137 geometric, 58, 137 harmonic, 137

#### **B**

Bernoulli variable, 288 Bokeh, 15 boundary condition open (radiation), 149 boundary conditions Dirichlet, 126 Neumann, 126 periodic, 151 boundary layer, 344

#### **C**

C extension module, 479

C/Python array storage, 483 cable equation, 316 \_\_call\_\_, 110 callback function, 104, 263 centered difference, 2 central difference approximation, 208 CFL condition, 329 Cholesky factorization, 282 class serial layers, 248 clock, 210 closure, 110 coefficients variable, 140 column-major ordering, 483 conjugate gradient method, 285 constrained motion, 79 continuation method, 386 parameter, 386 continuation method, 406 correction terms, 430 cosine hat, 144 cosine pulse half-truncated, 325 coupled system, 221, 367 Courant number, 160 cumsum, 290 cylindrical coordinates, 251, 314 Cython, 475 cython -a (Python-C translation in HTML), 478

#### **D**

Darcy's law, 312 decay ODE, 422 declaration of variables in Cython, 477 dense coefficient matrix, 260 diags, 223, 246 diff, 212 difference equations, 95

differential-algebraic equation, 79 diffu1D\_u0.py, 210, 223 diffu1D\_vc.py, 246 diffu2D\_u0.py, 266 diffusion artificial, 331 diffusion coefficient, 207 non-constant, 245 piecewise constant, 247 diffusion equation 1D, 207 1D, boundary condition, 208 1D, Crank-Nicolson scheme, 224 1D, dense matrix, 223 1D, discrete equations, 208 1D, explicit scheme, 208 1D, Forward Euler scheme, 208 1D, Fourier number, 208 1D, Implementation, 246 1D, implementation (FE), 210 1D, implementation (sparse), 223 1D, implicit schemes, 218 1D, initial boundary value problem, 208 1D, initial condition, 208 1D, mesh Fourier number, 208 1D, numerical experiments, 215 1D, sparse matrix, 223 1D, theta rule, 226 1D, tridiagonal matrix, 223 1D, verification (BE), 223 1D, verification (CN), 226 1D, verification (FE), 212 2D, 254 2D, banded matrix, 258 2D, implementation, 260 2D, implementation (sparse), 266 2D, numbering of mesh points, 255 2D, sparse matrix, 257 2D, verification (conv. rates), 265 2D, verification (exact num. sol.), 264 axi-symmetric diffusion, 251 diffusion coefficient, 207 implementation, 248 numerical Fourier number, 234 source term, 208 spherically-symmetric diffusion, 252 stationary solution, 207, 247 truncation error, 234 diffusion limit of random walk, 293 dimensional splitting, 387 dimensionless number, 208 Dirac delta function, 231 Dirichlet conditions, 126 discontinuous initial condition, 227 discontinuous medium, 144 discontinuous plug, 223 discrete derivative, 417

discrete Fourier transform, 157 discretization of domain, 2 discretization parameter, 100, 144 dispersion relation, 337 analytical, 160 numerical, 160 distutils, 479 DOF (degree of freedom), 68 domain, 208 dynamic viscosity, 312

#### **E**

efficiency measurements, 119 energy estimates (diffusion), 316 energy principle, 36 equation of state, 309 error global, 25 error function (erf), 229 complementary, 229 error mesh function, 24 error norm, 7, 25, 38 Euler-Cromer scheme, 40, 439 expectation, 288 explicit discretization methods, 208 extract\_leading\_order, 161

#### **F**

factor, 161 fast Fourier transform (FFT), 157 FD operator notation, 4 Fick's law, 308 finite difference scheme, 94, 95 finite differences backward, 417 centered, 2, 419 forward, 418 fixed-point iteration, 357 Flash (video format), 13 Fokker-Planck equation, 304 forced vibrations, 57 Fortran 77, 480 Fortran 90, 482 Fortran array storage, 483 Fortran subroutine, 481 forward difference approximation, 208 forward-backward scheme, 40 Fourier series, 157 Fourier transform, 157 Fourier's law, 309 fractional step methods, 387 free body diagram animated, 74 dynamic, 74 frequency (of oscillations), 1 friction, 315 functools, 119

#### **G**

Gaussian elimination, 223 Gaussian function, 144 Gaussian pulse, 325 Gauss-Seidel method, 277 geometric mean, 58, 137, 359 ghost cells, 132 points, 132 values, 132 Gnuplot, 177 Gnuplot.py, 177

#### **H**

harmonic average, 137 hash, 457 heat capacity, 309 heat conduction coefficient of, 311 heat equation, 207 homogeneous Dirichlet conditions, 126 homogeneous Neumann conditions, 126 HTML5 video tag, 13 Hz (unit), 1

#### **I**

ImageMagic, 15 incompressible fluid, 311 index set notation, 128, 173 initial condition triangular, 328 interior spatial points, 221 internal energy, 309 interpolation, 137 interrupt a program by Ctrl+c, 300 iterative methods, 270, 353

#### **J**

Jacobi iterative method, 270 Jacobian, 369 joblib, 457

#### **K**

kinetic energy, 36

#### **L**

lambda function (Python), 118 lambdify, 161 Laplace equation, 207, 227 leading order term, 161 Leapfrog method, 3 Leapfrog scheme, 237 limit, 356 linalg, 223, 246, 260, 262 linear system, 224, 270, 369 linearization, 357 explicit time integration, 355

fixed-point iteration, 357 Picard iteration, 357 successive substitutions, 357 load, 455 logistic growth, 388 logistic.py, 362 LU factorization, 282

#### **M**

making movies, 13 manufactured solution, 100 mass balance, 311 material derivative, 350 matrix equation, 221 form, 221 half-bandwidth, 282 positive definite, 285 Mayavi, 179 mechanical energy, 36 mechanical vibrations, 1 memoize function, 457 mesh finite differences, 2, 94 parameters, 155 uniform, 94 mesh function, 2, 94, 208 mesh points, 208 mlab, 179 MP4 (video format), 13

#### **N**

Navier-Stokes equations, 313 Neumann conditions, 126 neuronal fibers, 316 newaxis, 174, 263 Newton's 2nd law, 36 noise removing, 231 sawtooth-like, 229 nonlinear restoring force, 57 nonlinear spring, 57 norm, 25 nose, 6, 106 Numba, 114 Nyquist frequency, 157

#### **O**

ODE\_Picard\_tricks.py, 365 Odespy, 32, 392 Ogg (video format), 13 open boundary condition, 149 operator splitting, 387 oscillations, 1

#### **P**

padding zeros, 110

parallelism, 114 PDE algebraic version, 94 pendulum elastic, 79 physical, 74 simple, 71 period (of oscillations), 1 periodic boundary conditions, 151 phase plane plot, 32 Picard iteration, 357 plotslopes.py, 9 Plotter class (SciTools), 300 plug, 144 Poisson equation, 227 potential energy, 36 preconditioning, 285, 318 pulse propagation, 144 Pysketcher, 74 pytest, 6, 106

#### **Q**

quadratic convergence, 360 quadratic solution, 106, 118

#### **R**

radiation condition, 149 radioactive rock, 309 random, 290 random walk, 287 ready-made software, 221 red-black numbering, 278 relaxation, 271 relaxation (nonlinear equations), 361 relaxation parameter, 361 removeO, 161 reshape, 174 resonance, 88 Richardson iteration, 318 round-off error, 81 row-major ordering, 483

#### **S**

sampling (a PDE), 95 savez, 455 sawtooth-like noise, 227 scalar code, 115 scaling, 81 scaling equations, 113 SciTools, 11 scitools movie command, 14 scitools.avplotter, 300 seed (random numbers), 291 semi-explicit Euler, 40 semi-implicit Euler, 40 series, 161 setup.py, 479

signal processing, 231 simplify, 212 single Picard iteration technique, 358 skipping frames, 110 slice, 115 slope marker (in convergence plots), 9 smooth Gaussian function, 223 smoothing, 231 solver\_BE, 223 solver\_dense, 260 solver\_FE, 212 solver\_FECS, 325 solver\_FE\_simple, 210 source term, 99 sparse, 223, 246 sparse Gaussian elimination, 269 special method, 110 spectral radius, 283 spherical coordinates, 252 split\_diffu\_react.py, 392 split\_logistic.py, 388 split-step methods, 387 splitting ODEs, 387 spring constant, 36 spsolve, 223, 246, 269 stability, 234, 329 stability criterion, 26, 160 staggered Euler-Cromer scheme, 46 staggered mesh, 46, 439 stationary fluid flow, 313 stationary solution, 207 steady state, 227 stencil 1D wave equation, 94 Neumann boundary, 126 step function, 229 stiffness, 36 stochastic difference equation, 303 stochastic ODE, 304 stochastic variable, 288 Stoermer's method, 3 Stoermer-Verlet algorithm, 45 stopping criteria (nonlinear problems), 358, 371 storez, 455 Strang splitting, 388 stream function, 313 stress, 350 subs, 212 successive over-relaxation (SOR), 277 successive substitutions, 357 SuperLU, 269 switching references, 121 symmetric successive over-relaxation (SSOR), 285 symplectic scheme, 41 sympy, 22, 356, 412

system of algebraic equations, 367

#### **T**

Taylor series, 161, 235, 356 test function, 6, 106, 212 time, 210 time step spatially varying, 138 todense, 223 transport phenomena, 344 truncation error Backward Euler scheme, 417 correction terms, 430 Crank-Nicolson scheme, 419 Forward Euler scheme, 418 general, 415 table of formulas, 420 trunc\_decay\_FE.py, 425

#### **U**

uniform, 290 unit testing, 6, 106 upwind difference, 331

#### **V**

vectorization, 6, 114, 115, 290 vectorized code, 114 computing, 114 loops, 114 verification, 292, 433 convergence rates, 7, 44, 99, 100, 107, 214, 265, 392, 415, 461 hand calculations, 6 polynomial solution, 7, 106 Verlet integration, 3 vib\_empirical\_analysis.py, 19 vib\_EulerCromer.py, 43 vib\_plot\_freq.py, 22 vib.py, 59 vibration ODE, 1 vib\_undamped\_EulerCromer.py, 43 vib\_undamped\_odespy.py, 32 vib\_undamped.py, 4 vib\_undamped\_staggered.py, 49 video formats, 13

viscous boundary layer, 345 viscous effects, 312 visualization of 2D scalar fields, 177

#### **W**

wave complex component, 155 damping, 140 reflected, 135 transmitted, 135 variable velocity, 135 velocity, 93 wave equation 1D, 93 1D, analytical properties, 155 1D, discrete, 96 1D, exact numerical solution, 158 1D, finite difference method, 94 1D, implementation, 104 1D, stability, 160 2D, implementation, 171 wave1D\_dn.py, 128 wave1D\_dn\_vc.py, 141, 451 wave1D\_n0\_ghost.py, 132 wave1D\_n0.py, 127 wave1D\_oo.py, 462 wave1D\_u0.py, 105 wave1D\_u0v.py, 117 wave2D\_u0\_adv.py, 475 wave2D\_u0\_loop\_c.c, 486 wave2D\_u0\_loop\_c\_f2py\_signature.f, 488 wave2D\_u0\_loop\_c.h, 486 wave2D\_u0\_loop\_cy.pyx, 476 wave2D\_u0\_loop\_f77.f, 481 wave2D\_u0.py, 172, 475 waves on a string, 93 WebM (video format), 13 where, 290 Wiener process, 304 wrapper code, 481

#### **Z**

zeros, 6 zip archive, 455

### *Editorial Policy*

§1. Textbooks on topics in the field of computational science and engineering will be considered. They should be written for courses in CSE education. Both graduate and undergraduate textbooks will be published in TCSE. Multidisciplinary topics and multidisciplinary teams of authors are especially welcome.

§2. Format: Only works in English will be considered. For evaluation purposes, manuscripts may be submitted in print or electronic form, in the latter case, preferably as pdf- or zipped ps-files. Authors are requested to use the LaTeX style files available from Springer at: https://www.springer.com/gp/authors-editors/bookauthors-editors/manuscript-preparation/5636 (Click on ! Templates ! LaTeX ! monographs)

Electronic material can be included if appropriate. Please contact the publisher.

§3. Those considering a book which might be suitable for the series are strongly advised to contact the publisher or the series editors at an early stage.

### *General Remarks*

Careful preparation of manuscripts will help keep production time short and ensure a satisfactory appearance of the finished book.

The following terms and conditions hold:

Regarding free copies and royalties, the standard terms for Springer mathematics textbooks hold. Please write to martin.peters@springer.com for details.

Authors are entitled to purchase further copies of their book and other Springer books for their personal use, at a discount of 40% directly from Springer-Verlag.

### *Series Editors*

Timothy J. Barth NASA Ames Research Center NAS Division Moffett Field, CA 94035, USA barth@nas.nasa.gov

Michael Griebel Institut für Numerische Simulation der Universität Bonn Wegelerstr. 6 53115 Bonn, Germany griebel@ins.uni-bonn.de

David E. Keyes Mathematical and Computer Sciences and Engineering King Abdullah University of Science and Technology P.O. Box 55455 Jeddah 21534, Saudi Arabia david.keyes@kaust.edu.sa

#### and

Department of Applied Physics and Applied Mathematics Columbia University 500 W. 120 th Street New York, NY 10027, USA kd2112@columbia.edu

Risto M. Nieminen Department of Applied Physics Aalto University School of Science and Technology 00076 Aalto, Finland risto.nieminen@aalto.fi

Dirk Roose Department of Computer Science Katholieke Universiteit Leuven Celestijnenlaan 200A 3001 Leuven-Heverlee, Belgium dirk.roose@cs.kuleuven.be

Tamar Schlick Department of Chemistry and Courant Institute of Mathematical Sciences New York University 251 Mercer Street New York, NY 10012, USA schlick@nyu.edu

Editor for Computational Science and Engineering at Springer: Martin Peters Springer-Verlag Mathematics Editorial IV Tiergartenstrasse 17 69121 Heidelberg, Germany martin.peters@springer.com

# **Texts in Computational Science and Engineering**


*For further information on these books please have a look at our mathematics catalogue at the following URL:* www.springer.com/series/5151

# **Monographs in Computational Science and Engineering**

1. J. Sundnes, G.T. Lines, X. Cai, B.F. Nielsen, K.-A. Mardal, A. Tveito, *Computing the Electrical Activity in the Heart.*

*For further information on this book, please have a look at our mathematics catalogue at the following URL:* www.springer.com/series/7417

# **Lecture Notes in Computational Science and Engineering**


*For further information on these books please have a look at our mathematics catalogue at the following URL:* www.springer.com/series/3527